Logging

Harpe ships a general and flexible logging framework. It uses the framework to log every model call and runCode execution. The framework is also intended to be used by user programs to collect and store all events of an application in a unified way.

Three properties

The logging mechanism is built around three properties:

The structure of a log entry

Every logged event becomes an Entry:

class Entry(time: Float, event: String, fields: Map[String, Value], context: List[String])

type Value =
  (String | Float | Value.IntVal | Value.BoolVal | List[Value] | Map[String, Value])
    :- [Value.IntVal, Value.BoolVal]

section Value
  class IntVal(value: Int)
  class BoolVal(value: Bool)
end

IntVal and BoolVal are implementation adapters. At the call site, integers and booleans are passed directly, just like strings and floats. A field can also contain a list or nested map of Value values.

Where your events go

A Logger defines only the interface, so an implementation decides the storage format and the destination. The simplest logger just throws the records away, which is the logger behind Logging.discard. The framework ships JsonlLogger which appends JSON lines to a file:

{"time":"2024-07-09T16:00:00.400000Z","event":"harpe.tools.runCode.ran","fields":{"code":"…","exitCode":0,"compileSeconds":1.2,"runSeconds":0.3,"output":"…"},"context":[]}

JSONL encodes time as an RFC 3339 UTC string. The backend-independent Entry.time remains epoch seconds, so database loggers can choose their native timestamp representation and indexes.

To send them elsewhere, simply create a custom Logger and implement logEntry and close:

class SqliteLogger(db: py.Dynamic)
  view Logger

  def logEntry(entry: Entry): Unit =
    // insert entry.time, entry.event, entry.fields, entry.context (serialize as you wish)
    ...

  def close(): Unit = db.close()
end

To send logs to multiple destinations, use TeeLogger:

val log = new TeeLogger([new JsonlLogger(path), new ViewLogger(capacity = 5000)])

That is how the log viewer shows the logs live without displacing the file.

Framework-defined events

The framework writes these events on every turn. Each event name is predefined and can be used to query the logged events. transcript a reading of the log rather than a second place to record it.

EventFieldsWhat it records
harpe.tools.runCode.rancode, compileSeconds, runSeconds, exitCode, outputa program that compiled and ran
harpe.tools.runCode.compileFailedcode, compileSeconds, compileErrorit did not compile
harpe.tools.runCode.compileTimedOut / .timedOut / .approvalTimedOuta clock ran out during the build, the run, or the approval the run was waiting on
harpe.model.repliedprovider, modelone attempt that came back with a reply
harpe.model.failedprovider, model, error, status, retryableone attempt that did not, as attributable as a successful one. status is 0 when the request never reached the server
harpe.model.retried / harpe.model.gaveUpattempt, retryInSeconds / retrieswhat the engine decided about the harpe.model.failed record just before it
harpe.metering.usageprovider, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokensone call billed in tokens. The one record with a codec of its own — see Explicit contracts
harpe.tools.skills.read / .searchedname / querycontent reached through the skill tools
harpe.turn.request / harpe.turn.responsedatathe driver’s brackets around one turn
harpe.turn.messagerole, and what that role carriesone message of the conversation, whoever said it
harpe.turn.answered / harpe.turn.interruptedthe terminator that closes a turn
harpe.turn.failederrorthe same, for a turn that did not finish

Logging from your own tool

When creating a tool, the logger is already in scope inside the handler.

import harpe.logging.logger
import harpe.Interact
import harpe.Tool
import harpe.Tool.*
import harpe.Toolset

val weather: Tool =
  Tool:
    name = "weather"
    description = "Look up the weather in a city"
    params = [Tool.strParam("city", "the city")]

// The route's work goes in a small function. It may use `logger` freely.
private def lookUp(city: String): ToolOutcome receives logger =
  logger.info("myagent.tools.weather.called", "looked up weather", "city" ~ city)
  new ToolOutcome:
    "Sunny in \{city}"
    "weather · \{city}"

Quick reference

// emit (logger is in scope inside a tool handler)
logger.log(event, "k" ~ v, ...)                    // a data event
logger.logFields(event, fields)                     // the same, fields already a map
logger.info(event, message, ...)                    // an informational message
logger.warn(event, message, ...)                    // a warning
logger.error(event, message, ...)                   // an error

// install a Logger — where events go (in the driver's entry point)
Logging.withLogger(myLogger, () => run())           // myLogger: any Logger
Logging.withContext("myapp.session=42", () => ...)  // tag entries with a scope
Logging.discard                                     // a no-op Logger (tests, logging off)
new TeeLogger([first, second])                      // one entry, several destinations

// write your own Logger
interface Logger
  def logEntry(entry: Entry): Unit                  // the one method you implement
  def close(): Unit
end

class Entry(time: Float, event: String, fields: Map[String, Value], context: List[String])

Field values are String, Int, Float, Bool, List[Value], or a nested Map. Scalars are written bare at the call site.

Use Logging.withContext when several sessions share one logging destination. Per-session destinations do not need that redundant scope. Nesting it is safe: each scope is added to context rather than over the one enclosing it, so a record keeps every scope it was produced under.

A scope is a string, and an identity — stable for the unit of work it names and distinct between instances, by convention "<dotted key>=<id>":

Logging.withContext("myapp.session=\{id}", () => runTurn())

Detail about the unit goes in the fields of the records produced under it, where it can be queried, rather than in the name of the scope. That is what lets a reader group a log by structure alone — see Observability.