Skip to content

feat(trace): stamp the effective profile digest on every span - #219

Merged
drewstone merged 1 commit into
mainfrom
feat/span-node-identity
Sep 10, 2026
Merged

feat(trace): stamp the effective profile digest on every span#219
drewstone merged 1 commit into
mainfrom
feat/span-node-identity

Conversation

@drewstone

Copy link
Copy Markdown
Owner

The gap, measured

A trace with several agents in it could not be read per agent.

Measured 2026-09-10 against a live bridge's own export
(~/.local/state/cli-bridge-8913/data/traces/spans.jsonl, 94 spans at the time
of the check, names chat opencode and bash). Every span carried:

cli_bridge.run.id, cli_bridge.session.id, cli_bridge.backend.session_id,
cli_bridge.trace_contract.build, gen_ai.request.model, gen_ai.system,
token usage, gen_ai.tool.call.id, gen_ai.tool.name.

Zero carried any agent identity. With several agents sharing one bridge the
only discriminator left is gen_ai.request.model — and a model id is not an
identity. Two agents on one model are indistinguishable, and one agent whose
model changed reads as two. Asking "how are these agents collaborating?" of that
file has no grounded answer.

The join key existed on both sides the whole time:

  • agent-runtime records profileDigest and authoredProfileDigest per node on
    its spawn journal.
  • cli-bridge computes effectiveProfileDigest for every profile-materialization
    receipt (GET /v1/runs/:id) and every session profile binding.

It was missing from the one artifact a trace tool reads.

What this stamps

Attribute Value
cli_bridge.profile.effective_digest canonicalAgentProfileDigest of the effective profile — the join key
cli_bridge.profile.name the caller's agent_profile.name, verbatim — a readable label, not an identity

The digest is not a second computation that could disagree with the receipt.
resolveAgentProfile memoizes per request (WeakMap<ChatRequest, AgentProfile>),
so the span, the materialization receipt and the session binding all read one
profile object; this call site reuses the digest the binding just produced, so it
adds no hashing at all.

Placed at the point where the effective profile is settled for both sources
that can supply it — the request body and the session being resumed. The span
opens before that (its creator opens it before admission), so the identity is
recorded the way cli_bridge.backend.session_id already is: folded in once the
request establishes it. The resume case is the multi-agent one — an agent's turns
after the first carry no agent_profile on the wire.

Both attributes land on the tool spans as well as the request span, so
filtering the file to one agent's work does not require rebuilding the tree first.
Both are absent, never a placeholder, on a request that carried no
agent_profile: a synthesized label would be indistinguishable from one the
caller chose.

Why cli_bridge.* and not a new contract key

BRIDGE_ATTR already states the rule: anything a standard names uses the
standard key; anything describing THIS producer's execution context is namespaced
under cli_bridge.. Checked the installed contract
(@tangle-network/agent-trace-contract@1.0.2, the build
cli_bridge.trace_contract.build pins): its ATTR vocabulary covers span kind,
model, system, tokens, cost, tool name, and agent.loop.* / agent.branch.* /
agent.outcome. It names no agent identity, so there is no standard key to
use and no contract change to make here. The addition goes through BRIDGE_ATTR,
the emitter's documented vocabulary, and the README's bridge-attribute table —
not as a key bolted onto the span builder.

Safety

  • A digest, never the profile. A profile carries prompts, instructions and file
    paths; the existing rule that a trace file must not become a second copy of what
    the agent read applies to it too. Same reason tool arguments are dropped.
  • Bounded by the same MAX_ATTR_CHARS as every other attribute, so a pathological
    caller-supplied name cannot blow up a span.
  • recordNode cannot throw into a request, like every other entry point on the
    recorder. The failure mode stays "a lost stamp", never "a lost completion".
  • No new failure mode on the request path: the digest this reuses was already
    computed unconditionally one line earlier by exactSessionProfileBinding.

Tests

tests/trace-node-identity.test.ts, 12 cases, red before the change and green
after (verified by reverting the route call site alone: 7 route-level cases fail
with expected undefined to be 'sha256:…').

Route-level, through mountChatCompletions with a capturing sink:

  1. a request carrying agent_profile stamps the digest and the name;
  2. the stamped digest equals the effectiveProfileDigest the run stored — the
    join key cannot drift from the receipt;
  3. a resumed turn whose profile lives on the session, not in the body, is
    stamped;
  4. two agents on one model separate by digest while gen_ai.request.model
    agrees across both — the gap itself;
  5. tool spans carry it, with the pre-existing tool attributes untouched;
  6. a failed turn is still attributed;
  7. a profile with no name stamps the digest and omits the name;
  8. a request with no profile stamps nothing.

Recorder-level: last-write-wins, empty digest ignored, empty name omitted,
pathological name bounded to 256 chars.

tsc --noEmit clean. Full suite: 1062 passed / 12 failed. 11 of those 12 fail
identically on unmodified origin/main in this same environment (Docker
executor, macOS seatbelt jail, Pi session-dir realpath — all environment-bound,
none touching the trace path). The 12th,
retained-sessions > prunes acknowledged interaction records, is a 5s-timeout
flake under full-suite load: it passes in isolation and in a five-file run with
this change applied, and this change adds no work to that path.

Deployment

Deployment waits for a run boundary. Bridge 8913 is serving a live
multi-agent workload; restarting it kills in-flight turns. Do not restart or
redeploy 8913 or 8912 to pick this up — land it, then deploy at the next run
boundary.

Spans already emitted are not retrofitted by this change. They can still be
attributed after the fact by joining the run id or session id on the span to the
bridge's own retained_run_admissions.snapshot_json.profileMaterialization and
sessions.metadata_json.agent_profile_binding, which is what an out-of-tree
retrofit script does for the current run. Measured on the same file: 45 of 92
turns attributable that way, the other 47 being requests that carried no
agent_profile at all and so have no identity to recover. After this lands, none
of that joining is needed.

🤖 Generated with Claude Code

A trace with several agents in it could not be read per agent. Every span
carried the run id, both session ids, the model and the token usage, and no
agent identity — so with several agents sharing one bridge the only
discriminator left was `gen_ai.request.model`, which is not an identity: two
agents on one model are indistinguishable, and one agent whose model changed
reads as two. Measured 2026-09-10 on a live multi-agent trace file: 94 spans,
zero carrying any agent identity.

The join key existed on both sides the whole time. agent-runtime records
`profileDigest` / `authoredProfileDigest` per node; the bridge computes an
`effectiveProfileDigest` for every materialization receipt and every session
profile binding. It was missing only from the one artifact a trace tool reads.

`cli_bridge.profile.effective_digest` closes that. The value is not a second
computation that could disagree with the receipt: `resolveAgentProfile`
memoizes per request, so the span, the receipt and the binding read one
profile object and hash it once. `cli_bridge.profile.name` carries the
caller's own `agent_profile.name` beside it as a readable label, never as an
identity.

Recorded from the route the moment the effective profile is settled, which is
after the span opens — the run's creator opens the span before the profile is
resolved, and the profile may arrive on the request OR be carried by the
session being resumed. That second case is the multi-agent one: an agent's
turns after the first carry no profile on the wire.

Both attributes land on the tool spans as well as the request span, so
filtering the file to one agent's work needs no tree walk first. Both are
absent — never a placeholder — on a request that carried no `agent_profile`.
A digest, not the profile: a profile carries prompts and instructions, and the
existing rule that a trace file must not become a second copy of what the
agent read applies to it too.

The attributes are namespaced under `cli_bridge.` per the rule already stated
on BRIDGE_ATTR: no semantic convention names agent identity, and the contract
package's own `agent.*` vocabulary covers loops, branches and outcomes only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014G45Uj6RV5945HXvEkKnfh
@drewstone
drewstone merged commit 1d19faf into main Sep 10, 2026
1 check passed
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