Skip to content

feat: trace recorder for inference / system / tools + content provenance#21

Merged
yanmxa merged 2 commits into
trace/2-schema-renamefrom
trace/3-recorder
May 15, 2026
Merged

feat: trace recorder for inference / system / tools + content provenance#21
yanmxa merged 2 commits into
trace/2-schema-renamefrom
trace/3-recorder

Conversation

@yanmxa
Copy link
Copy Markdown
Member

@yanmxa yanmxa commented May 15, 2026

PR 3 of 5 — stacked on #20.

Adds the structured event records that make sessions traceable.

`session.Recorder` subscribes to `core.Agent.OnEvent` and writes:

  • `inference.requested` / `inference.responded` — digests of system prompt, tool list, active message chain (request); stop reason, latency, token usage (response).
  • `system.section.added` / `system.section.removed` — every mutation of `core.System`. `Use(sec, caller)` / `Drop(name, caller)` take explicit caller strings. `SetObserver` replays existing sections on attach.
  • `tools.added` / `tools.removed` — same pattern on `core.Tools`. MCP registrations use `caller="mcp:"`.
  • `ContentBlock.Source = "reminder"` — user-message content is split on `` boundaries. Round-trip safe.

Wiring: `core.Config.OnEvent` exposed via new `agent.BuildParams.OnEvent`. Non-blocking `emitTelemetry` so observer events don't deadlock the agent goroutine.

Test

  • `go build ./...` clean
  • `go test ./...` — 50 packages pass, 0 FAIL
  • New tests: `TestRecorderWritesRequestedAndRespondedPerTurn`, `TestRecorderWritesSystemSectionEvents`, `TestRecorderWritesToolsChangeEvents`, `TestRecorderNilSafe`, `Test_userContentToBlocks_splitsByProvenance`

🤖 Generated with Claude Code

@yanmxa yanmxa mentioned this pull request May 15, 2026
2 tasks
Adds session.Recorder, a synchronous observer on core.Agent's event bus
that translates lifecycle events into transcript records. Wired via
core.Config.OnEvent (new BuildParams.OnEvent passthrough; constructed by
Session.NewRecorder at agent start).

New records per turn:
  inference.requested  digests of system prompt / tool list / message chain
  inference.responded  stop reason, latency, token usage

System observability:
  Use(sec, caller), Drop(name, caller) on core.System require explicit
  caller strings (system:init, command:identity, subagent:init).
  System.SetObserver replays existing sections on attach so the event
  chain is complete from t0. Recorder writes system.section.added/removed.

Tools observability:
  Same pattern on core.Tools: Add/Remove gain caller, SetObserver replays.
  MCP registrations pass caller="mcp:<toolname>". Both wrappers
  (permissionTools, progressTools) pass-through. Recorder writes
  tools.added (with schema) / tools.removed (with name).

Content provenance:
  splitTextByProvenance splits user-message content on <system-reminder>
  boundaries and tags those blocks with Source="reminder". Round-trip
  safe: extractUserContent concatenates back, preserving
  core.Message.Content byte-for-byte.

Non-blocking telemetry: agent.emitTelemetry uses select-default on the
outbox so observer-fired events never deadlock the agent goroutine on a
slow TUI consumer.

Tests: 4 new Recorder tests + 2 provenance tests.
@yanmxa yanmxa force-pushed the trace/3-recorder branch from 9af803d to f0e52cb Compare May 15, 2026 14:29
@yanmxa yanmxa force-pushed the trace/2-schema-rename branch from 238e838 to e714a07 Compare May 15, 2026 14:29
NewRecorder now calls FileStore.Start synchronously before returning, so
session.started lands on disk first. Previously the call order
  1. app builds Recorder
  2. core.NewAgent wires System/Tools.SetObserver
  3. SetObserver replays existing members → AppendSystemSection/AppendTools
     creates the transcript file
  4. Store.Save → Start → file exists, no-op
left provider/model/parentID metadata absent from every transcript that
involved any observer replay (i.e. every main-agent session). Resume
and the projects index would show empty Provider/Model.

Fix lives in the recorder constructor because that's the latest point
before observers can fire. RecorderOptions gains Cwd and ProjectID so
Start can be called with the right metadata. Setup.NewRecorder threads
them through from Store.cwd/Store.projectID.

Test: TestRecorder_WritesSessionStartedBeforeTelemetry asserts that
after NewRecorder() returns and an OnSystemChange event is fired, the
first record on disk is session.started with provider+model populated.
@yanmxa yanmxa force-pushed the trace/3-recorder branch from 9e56da5 to 606aa68 Compare May 15, 2026 15:13
@yanmxa
Copy link
Copy Markdown
Member Author

yanmxa commented May 15, 2026

Confirmed and fixed in 606aa68 (force-pushed; #22 and #23 rebased).

The bug was exactly as described:

  1. `Setup.NewRecorder` returns a Recorder
  2. `core.NewAgent` calls `System.SetObserver` / `Tools.SetObserver` which replay existing members as synthetic events
  3. The recorder writes those telemetry events via `AppendSystemSection` / `AppendTools`, creating the transcript file
  4. Turn ends → `Store.Save` → `Start` → `fileExists() == true` → no-op
  5. `session.started` never written; `provider`/`model`/`parentID` lost on resume

Fix: `NewRecorder` now calls `FileStore.Start` synchronously before returning. `RecorderOptions` gains `Cwd` + `ProjectID` so it has enough to populate the start record. `Setup.NewRecorder` threads them through from the `Store`. `Start` remains idempotent, so the later `Save` call is still a safe no-op.

Test: `TestRecorder_WritesSessionStartedBeforeTelemetry` (in `recorder_lifecycle_test.go`) — constructs a recorder, fires `OnSystemChange`, asserts the first on-disk record is `session.started` with provider+model populated. This test failed before the fix and passes after.

All 5 stack branches rebuilt + verified independently.

@yanmxa yanmxa merged commit 15323a9 into trace/2-schema-rename May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant