Skip to content

Lift [tc] prompt headers into every agent span's metadata (1.3.0) - #5

Merged
miguel-border merged 2 commits into
mainfrom
feat/tc-header-lift
Oct 19, 2026
Merged

miguel-border merged 2 commits into
mainfrom
feat/tc-header-lift

Conversation

@miguel-border

@miguel-border miguel-border commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Linked: Border-Innovation/topcodearchiteture#596 (the reader that consumes these fields).

What this changes

A launcher's first prompt line, [tc migration=m batch=b fqn=f phase=p] or [tc work=harness pr=1 issue=], now reaches Langfuse as metadata on the agent it belongs to. This holds at any depth, for workflow agents and for background agents.

  • tc_<key> for each header pair. The header is read from the agent's full prompt rows. It is never read from the span input, which is truncated to CC_LANGFUSE_MAX_CHARS. Empty values (pr= issue=) are dropped; they no longer void the whole header.
  • tc_source is own, inherited or none.
    • An agent with no header of its own inherits the effective header of the agent, or main-thread turn, that launched it.
    • A registered agent keeps what it inherited when a later firing refreshes it outside its launcher.
    • An own header replaces the inherited one whole. Keys are never merged.
  • Workflow agents. The computed task is read before the relayed user request above it, so a workflow agent's own header wins.
  • Root span. A main-thread turn's own header goes on its root span. When the root ends it also gets tc_session_agents: the session's agents so far, counted as agents, own, inherited and unattributed. A reader can then show an "unattributed" line instead of a zero.
  • Trace tags. migration and work also become trace tags, for example tc:migration=m1.
  • Backfill dry run. The dry run prints agent_tc_sources, so coverage can be checked before anything is uploaded.
  • Off switch and version. CC_LANGFUSE_TC_HEADERS=false turns the feature off. The version is now 1.3.0.

Why headers never reached Langfuse: root cause, with evidence

The evidence is a read-only probe of this machine's Langfuse (v2 observations API) and its transcripts, for one long session.

  • Cause 1, the main one: the hook that ran was 1.2.1, which reads Claude Code's new api-request-blob rows as user turns.
    • Claude Code 2.1.301 started writing api-request-shape, api-request-blob and api-request rows after each prompt. A blob row has message.role: "user" and holds the injected <system-reminder> context (CLAUDE.md, memory), about 117,000 characters long.
    • 1.2.1's get_user_or_assistant_role_from_row falls back to message.role, so a blob starts a new turn. The real prompt turn then has no assistant reply, and build_turn_from_state drops it.
    • The agent span's input becomes the injected context, truncated at 20,000 characters, so the header is gone.
    • Agent spans by day, comparing the span input in Langfuse with the prompt on disk:
      • Oct 13–14: 190 agents had a header on disk, and all 190 had it in Langfuse.
      • Oct 15–18: 167 agents had a header on disk, and 0 had it in Langfuse. Every input starts with <system-reminder>.
    • This was already fixed in this repo's 1.2.2: is_request_echo_row in read_subagent_jsonl, plus "a typed row is never a chat row". But 1.2.2 never ran on this machine. 1.2.2 writes langfuse_agents.<key>.json beside the state file on every firing, and ~/.claude/state has none. Running sessions keep the hook version they started with, including after claude plugin update (1.2.2 was installed on Oct 17, 13:55).
  • Cause 2: the plugin never lifted a header at all. The reader had to regex the span input, which breaks under truncation and injected prefixes. That is why this PR lifts the header from the prompt rows into metadata.
  • Cause 3: empty values voided the header. The [tc work=<kind> pr=<n> issue=<n>] convention is often sent with blanks, such as pr= issue=. A strict key=value+ pattern rejected the whole header.
  • Nested and Workflow capture: no further fix was needed in 1.2.2's code.
    • Under 1.2.1, about 20% of depth-2 and depth-3 agents were missing in Langfuse. For example, on Oct 13, 38 of 127 depth-2 agents were missing.
    • A dry-run backfill of the same session with 1.2.2's code builds 2,843 agent spans for the 2,844 agent transcripts on disk (Workflow agents included).
    • With this PR, that dry run reports agent_tc_sources = {own: 472, inherited: 459, none: 1912}. The 1,912 agents with no header to own or inherit are mostly older Workflow rounds and pre-convention agents. That count is the true unattributed population, not a defect of the lift.

Tests

  • tests/integration/test_tc_headers.py has 19 tests.
  • The fixture is sliced from real Claude Code 2.1.x transcripts, and every text body is scrubbed down to its header line, with neutral values:
    • a three-deep agent chain: own, own, then inherited;
    • an agent whose prompt is followed by api-request echo rows;
    • a headerless agent under a headed turn;
    • the workflow fixture rewritten to the real two-row workflow prompt shape.
  • Negative controls. Each of these mutations fails exactly the tests that name it:
    • no inheritance (4 tests fail);
    • empty values rejected (6);
    • the first prompt row wins (1);
    • 1.2.1's echo-row reading, with both of 1.2.2's guards removed (1).
  • uv run --group dev pytest: 378 passed, on Python 3.11 and 3.10.
  • The ruff F,E9 check and the echo '{}' | uv run --script hooks/langfuse_hook.py smoke check are clean.

Owner: install (not done by this PR's author)

After merge:

claude plugin marketplace update langfuse-observability
claude plugin update langfuse-observability@langfuse-observability
claude plugin list   # expect langfuse-observability 1.3.0 for both the project and the local install entries
  • Restart every running Claude Code session, resumed ones included. A running session keeps the hook it started with.
  • Check that the new hook is running. After the next Stop, ls ~/.claude/state/langfuse_agents.*.json lists a file. In Langfuse, an agent span then carries tc_source.
  • Optional, and it writes to Langfuse: re-upload past sessions with the headers.
    • Run uv run --script hooks/langfuse_hook.py --backfill <session>.jsonl --dry-run first, and read agent_tc_sources.
    • Then run with a new --namespace and --replace-session-traces, as described in the README's backfill section.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy

Miguel Pereira and others added 2 commits October 19, 2026 00:20
A launcher's first prompt line "[tc migration=m batch=b fqn=f phase=p]" now
reaches Langfuse as observation metadata on the agent it belongs to, at any
depth, for workflow and background agents alike:

- tc_<key> per header pair, read from the agent's full prompt rows (never
  from the span input, which is truncated to CC_LANGFUSE_MAX_CHARS); empty
  values ("pr= issue=") are dropped instead of voiding the header.
- tc_source own | inherited | none. An agent with no header inherits the
  effective header of the agent or main-thread turn that launched it; a
  registered agent keeps what it inherited when a later firing refreshes
  it outside its launcher. An own header replaces the inherited one whole.
- A workflow agent's computed task is read before the relayed user request.
- The main-thread turn's own header goes on its root span, and the root
  gets tc_session_agents (agents / own / inherited / unattributed) when it
  ends, so a reader can show an unattributed line rather than a zero.
- migration and work become tc:<key>=<value> trace tags.
- The backfill dry run prints agent_tc_sources, to check coverage first.

CC_LANGFUSE_TC_HEADERS=false turns it off. Version 1.3.0.

Tests: tests/integration/test_tc_headers.py over a fixture sliced from real
Claude Code 2.1.x transcripts with every body scrubbed to its header line
(three-deep agent chain, an agent whose prompt is followed by api-request
echo rows, a headerless agent), plus the workflow fixture with the real
two-row workflow prompt shape. Each assertion was checked against a
mutation: no inheritance, empty values rejected, first prompt row winning,
and the 1.2.1 echo-row reading all fail the tests that name them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy
…a zero

Without deterministic ids no agent is registered, so tc_session_agents would
read as 'no agents' instead of 'not counted'. The root now leaves it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga4FKjMftJRxoPaQaiPLHy
@miguel-border

Copy link
Copy Markdown
Collaborator Author

Refute pass (independent): GO at 95b848f. No Medium or higher findings. The Lows are below.

What was verified

  • The suite passes: uv run pytest, 378 passed.
  • Real data, full session (Claude Code 2.1.301, 1,511 api-request-blob rows, 3.1 GB, 2,849 agents):
    • A frozen copy of the session went through backfill_session with the in-repo RecordingLangfuse, so nothing was sent.
    • Each agent's tc_* was compared with an expectation computed separately: a naive scan of the prompt, plus the parentAgentId chain from meta.json.
    • All 2,848 emitted agents matched: 477 own, 459 inherited, 1,912 none, depth 1 to 3, 457 with blob rows.
    • No headerless agent picked up a sibling's header.
    • The one agent that was not emitted is also missing under 1.2.2, so it is not a regression.
  • Workflow agents: the 1,224 workflow agents on disk have no header of their own and sit under headerless main turns, so none is correct for them. Inheritance was checked only through the PR's synthetic test.
  • Parser against real prompts: on every main-thread prompt (44 sessions) and every agent prompt, it agrees with the naive scan. The only main-thread lift is a deliberate smoke prompt.
  • Task notifications: a real one that mentions headers is not lifted. These rows never start a turn (add_task_notification_row consumes them), so tool output cannot reach get_turn_tc_header.
  • Live path, two firings: a nested child first seen in a later firing, during the parent's refresh, still inherits the right header, and every span keeps the same tc values across emissions.
  • No regression against 1.2.2 (origin/main): usage, generations, per-agent usage and span ids are identical. This holds on the full session and on 5 older transcripts (Sep 22 to Oct 12, 1 to 259 turns), with no errors logged.
  • Time: the full-session dry run takes 42 s warm on 1.3.0 and 44 s on 1.2.2, well inside the 600 s hook timeout.
  • Version: plugin.json says 1.3.0, and marketplace.json has no version field.
  • Root cause: 1.2.1's role fallback (message.role on typed rows) is confirmed, and ~/.claude/state has no langfuse_agents.*.json.

Lows

  • L1: tc_session_agents can be all zeros on a root whose turn did launch agents (hooks/langfuse_hook.py:4179).
    • Repro: firing 1 has the main turn plus its Agent tool_uses, with no results yet. The open-turn root ships then, with {"agents":0,…} while 3 agents are pending.
    • This is the "zero reads as no agents" case the 95b848f commit set out to avoid. It is correct only as "registered so far", and rare in real Stop timing.
    • Fix: omit the count when the registry has no entries, or document it.
  • L2: a line starting [tc …] in pasted text is lifted (:2450, :2454).
    • The line can be in a fenced block or a compaction summary, within the first 4,000 chars.
    • Example: "Here is the output:\n```\n[tc migration=pasted]" gives {migration: pasted}.
    • A backticked placeholder on the first line is lifted with its placeholder values: Each brief starts `[tc migration=X batch=<id>]` gives batch: <id>.
    • This matches the README. Real-data false positives: 0.
  • L3: the whole header is dropped silently when the grammar is strict (:2442).
    • Cases: an uppercase key ([tc Migration=m1]), a hyphenated key (work-kind=), an NBSP separator, or a header that crosses the 4,000-char window.
    • All of these give None with no debug line. Consider a debug log when "[tc" is present but nothing parses.
  • L4: a trace tag can go over the SDK's 200-char limit (:2583).
    • Values are capped at 200, but tc:migration= plus a 188 to 200 char value is longer than 200.
    • Langfuse 4.17 propagation._validate_string_value then drops that tag with a warning.
  • L5: sessions that span the upgrade (:2542, :3754).
    • Registry entries written by 1.2.2 have no tc_source, so count_tc_sources leaves them out with no "uncounted" line.
    • On refresh they also have no tc_inherited, so they resolve against the empty refresh context and become none.
  • L6: a workflow agent whose only header is in the relayed user request gets tc_source: own (:2487).
    • The values are the launching session's, so inherited would describe them more truly.

The repro test for L1 and the two-firing check is kept locally (not pushed).

@miguel-border
miguel-border merged commit fa73f01 into main Oct 19, 2026
5 checks 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