Observing runs

Evaluators tell you what failed; the trace tells you why the agent didn't fix it. Every run can stream its telemetry — loop outcomes plus the agent's inner trajectory — to a JSONL file, to standard OTLP spans, or to an observer you write yourself.

Three layers of evidence

A run produces evidence at three depths. Each answers a different question:

LayerWhat it holdsQuestion it answers
Outcome LoopReport: outcome, reason, warnings, total usage, overall diff Did it work, and can I trust the green?
Iteration Per-iteration evaluations, changed files, diff stat, stop reason, usage Which check blocked the loop, and when?
Trajectory AgentEvent stream: turns, model messages, tool calls/results, errors What was the agent actually doing in there?

The first two layers have always been in the report (--report run.json). Observability adds the third — drivers emit a vendor-neutral event stream from inside each driver.run(), and the engine forwards it tagged with the run id and iteration. The forwarding is crash-proof: a throwing sink can never fail an iteration.

The quickest path: --trace

loopgen run my.loop.yaml --trace run-trace.jsonl

Writes a JSONL execution trace — one JSON object per line, in five record kinds: run.start, agent.event, iteration.end, signal, run.end. Trust-guard warnings become signal records (deduplicated, iteration scope preferred over run scope), so a suspicious green is visible in the trace, not just the terminal. Works offline:

npm run loopgen -- run examples/building-blocks/mock-demo.loop.yaml --trace trace.jsonl

Observers in the spec

For anything beyond a one-off file, declare observers in the spec. An Observer is the fourth plug-in point, alongside drivers, evaluators, and task types: a named, spec-referenceable consumer of a run's telemetry. Resolution mirrors the other plug-ins (uses / as / options):

observability:
  observers:
    - uses: jsonl
      options: { file: trace.jsonl }
    - uses: otlp
      options:
        file: trace.otlp.json
        endpoint: http://localhost:4318/v1/traces

Observer guarantees, in the same spirit as the trust guards:

The jsonl observer

The offline default: writes the same execution trace as --trace, using the engine's run id as the trace id so the file correlates with the report.

OptionDefaultMeaning
fileloopgen-trace.jsonl Trace file path; relative paths resolve against the run's base dir.

The otlp observer

Assembles the run into standard OTLP/JSON spans with zero OpenTelemetry dependency. The tree nests four levels — a run root span, an iteration span per loop iteration, a turn span per agent turn, and a child span per tool call beneath its turn — with model output on the turn span and warnings as span events. Drivers that emit no turn detail simply produce a flatter tree (tools hang directly off the iteration).

OptionDefaultMeaning
fileloopgen-trace.otlp.json Output file; relative paths resolve against the run's base dir.
serviceNameloop-generator service.name resource attribute on the emitted spans.
endpoint OTLP/HTTP traces URL to POST the spans to, used verbatim (e.g. http://localhost:4318/v1/traces). When omitted, falls back to the standard OTEL_EXPORTER_OTLP_TRACES_ENDPOINT / OTEL_EXPORTER_OTLP_ENDPOINT env vars; no endpoint means file-only.
headers Extra HTTP headers for the export (e.g. an auth token / write key).
timeoutMs10000 Abort the export POST after this many ms.

Because the output is plain OTLP, it lands anywhere OTLP lands: Jaeger, Grafana Tempo, Honeycomb, an agent-trace debugger like Raindrop Workshop, or your own collector. The tool is vendor-neutral by design — bring your own tracing.

Driver event fidelity

Drivers emit whatever their backend exposes and skip what they can't observe — honesty over uniformity. What you'll see per driver today:

DriverTrajectory detail
claude-agent-sdk Fine-grained, live. Model messages, tool calls and results — each stamped with its agent turn, so the OTLP tree nests run → iteration → turn → tool.
github-copilot Coarser post-parse trajectory; model output is turn-tagged, so it gets turn spans.
grok Coarse post-parse trajectory + errors; no turn detail, so its spans stay flat on the iteration.
opencode Coarse post-parse trajectory + errors.
mock No agent events — traces carry the loop-level records only (which is why the offline demo's trace has no tool calls).

Writing an observer

Implement Observer: begin(info) returns a per-run session with any of three optional hooks. Register it like any other plug-in.

import { type Observer, createDefaultRegistries, LoopEngine } from "loop-generator";

const slack: Observer = {
  name: "slack",
  begin({ runId, spec, log }) {
    return {
      onIteration(report) { /* per-iteration report */ },
      onAgentEvent(event, { iteration }) { /* live trajectory */ },
      async onRunEnd(report) {
        // awaited by the engine — safe to flush a network export
        await postToSlack(`${spec.name}: ${report.outcome}`);
      },
    };
  },
};

const registries = createDefaultRegistries();
registries.observers!.register(slack); // always present from createDefaultRegistries; the field is optional on the type
const engine = new LoopEngine(registries);

Hooks must be side-effect-only and non-throwing; the engine isolates every call regardless. An optional preflight validates options before the run spends anything.

Library seams

Embedding the engine instead of running the CLI? Two lower-level seams:

The source of truth is the code: src/observers/types.ts (the contract), src/observability/ (the trace model), src/drivers/types.ts (AgentEvent).