From 2adb2267f3567f902d8e36a9f9c47d72a5416ead Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Tue, 28 Jul 2026 16:47:29 +0100 Subject: [PATCH 1/2] feat(claude-code): capture subagent text and thinking in transcripts Bump the pinned Claude Code CLI to 2.1.220 and run with --forward-subagent-text and --thinking-display summarized, so subagent text/thinking blocks land in the stream-json transcript (tagged with parent_tool_use_id) and thinking carries summarized content instead of the empty blocks Opus 5-era models stream by default. The Claude Code parser tags forwarded lines with an agnostic subagent ref; the shared parser layer stays agent-agnostic. Subagent parts are captured in the adapted transcript for visibility but excluded from the main-thread surface (agentReport, steps, toolCalls) and from serializeTranscript output unless opted in, so judge inputs are unchanged. Co-Authored-By: Claude Fable 5 --- .../src/agents/claude-code/parser.test.ts | 179 ++++++++++++++++++ .../core/src/agents/claude-code/parser.ts | 38 +++- .../core/src/agents/claude-code/runner.ts | 13 +- packages/core/src/index.ts | 40 +++- packages/core/src/parsers/adapt.ts | 23 ++- packages/core/src/transcript/types.ts | 17 ++ 6 files changed, 301 insertions(+), 9 deletions(-) diff --git a/packages/core/src/agents/claude-code/parser.test.ts b/packages/core/src/agents/claude-code/parser.test.ts index 75c7f42f..f89baea4 100644 --- a/packages/core/src/agents/claude-code/parser.test.ts +++ b/packages/core/src/agents/claude-code/parser.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { claudeCodeParser } from './parser.js'; import { adaptTranscript } from '../../parsers/adapt.js'; +import { serializeTranscript } from '../../index.js'; /** A representative Claude Code `--print` JSONL session. */ const SESSION = [ @@ -77,6 +78,93 @@ const SESSION = [ }), ].join('\n'); +/** + * A session with `--forward-subagent-text`: subagent lines are ordinary + * assistant/user lines whose top-level `parent_tool_use_id` names the + * spawning Agent tool_use, with `subagent_type` / `task_description` + * alongside (captured from Claude Code 2.1.220). + */ +const SUBAGENT_SESSION = [ + // main thread spawns the subagent (Task's 2.1.2xx name is `Agent`) + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_spawn', + name: 'Agent', + input: { + description: 'Check primality', + subagent_type: 'general-purpose', + prompt: 'Which of 91, 97, 100 are prime?', + }, + }, + ], + }, + }), + // the subagent's kickoff prompt, echoed as a forwarded user line + JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'Which of 91, 97, 100 are prime?' }], + }, + parent_tool_use_id: 'toolu_spawn', + subagent_type: 'general-purpose', + task_description: 'Check primality', + }), + // the subagent thinking (summarized) then replying + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: '97 is prime; 91 and 100 are not.' }, + ], + }, + parent_tool_use_id: 'toolu_spawn', + subagent_type: 'general-purpose', + task_description: 'Check primality', + }), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: '[97]' }], + }, + parent_tool_use_id: 'toolu_spawn', + subagent_type: 'general-purpose', + task_description: 'Check primality', + }), + // the Agent tool_result closing the spawn, back on the main thread + JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_spawn', content: '[97]' }, + ], + }, + parent_tool_use_id: null, + }), + // main thread wraps up; the result line repeats it and must dedup + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'The subagent says only 97 is prime.' }], + }, + parent_tool_use_id: null, + }), + JSON.stringify({ + type: 'result', + subtype: 'success', + result: 'The subagent says only 97 is prime.', + }), +].join('\n'); + describe('claudeCodeParser', () => { it('normalizes tool names while preserving the original, and pairs results by id', () => { const { events, errors } = claudeCodeParser.parseTranscript(SESSION); @@ -161,6 +249,46 @@ describe('claudeCodeParser', () => { ]); }); + it('tags forwarded subagent lines with the agnostic subagent ref', () => { + const { events, errors } = + claudeCodeParser.parseTranscript(SUBAGENT_SESSION); + expect(errors).toEqual([]); + + // The Agent spawn (Task's 2.1.2xx name) normalizes to agent_task. + const spawn = events.find((e) => e.type === 'tool_call'); + expect(spawn?.tool?.name).toBe('agent_task'); + expect(spawn?.tool?.originalName).toBe('Agent'); + expect(spawn?.subagent).toBeUndefined(); + + const tagged = events.filter((e) => e.subagent); + expect(tagged.map((e) => e.type)).toEqual([ + 'message', // the subagent's kickoff prompt + 'thinking', + 'message', // the subagent's reply + ]); + for (const e of tagged) { + expect(e.subagent).toEqual({ + id: 'toolu_spawn', + type: 'general-purpose', + description: 'Check primality', + }); + } + expect(tagged[1].content).toBe('97 is prime; 91 and 100 are not.'); + }); + + it('dedups the result line against the main thread even when a subagent spoke last', () => { + // A subagent reply must not register as "the last assistant message" — + // otherwise a result line repeating the main thread's final text would be + // emitted twice (or a subagent echo would swallow it). + const { events } = claudeCodeParser.parseTranscript(SUBAGENT_SESSION); + const mainAssistant = events.filter( + (e) => e.type === 'message' && e.role === 'assistant' && !e.subagent + ); + expect(mainAssistant.map((e) => e.content)).toEqual([ + 'The subagent says only 97 is prime.', + ]); + }); + it('skips lines with no content and never throws on malformed lines', () => { const { events, errors } = claudeCodeParser.parseTranscript( 'not json\n' + JSON.stringify({ type: 'system', subtype: 'init' }) @@ -202,6 +330,57 @@ describe('adaptTranscript', () => { ]); }); + describe('with forwarded subagent lines', () => { + const subagentAdapted = adaptTranscript( + claudeCodeParser.parseTranscript(SUBAGENT_SESSION).events + ); + + it('keeps the report, steps, and tool-call records on the main thread', () => { + expect(subagentAdapted.agentReport).toBe( + 'The subagent says only 97 is prime.' + ); + expect(subagentAdapted.steps).toBe(1); + // Only the main thread's Agent spawn — no subagent-attributed records. + expect(subagentAdapted.toolCalls.map((c) => c.endpoint)).toEqual([ + 'Agent', + ]); + }); + + it('captures subagent messages and thinking as tagged transcript parts', () => { + const tagged = subagentAdapted.transcript.filter( + (p) => 'subagent' in p && p.subagent + ); + expect(tagged.map((p) => p.type)).toEqual([ + 'message', + 'thinking', + 'message', + ]); + expect( + tagged.find((p) => p.type === 'thinking')?.content + ).toBe('97 is prime; 91 and 100 are not.'); + }); + + it('serializes identically to a subagent-free transcript by default', () => { + const serialized = serializeTranscript(subagentAdapted.transcript); + expect(serialized).not.toContain('97 is prime; 91 and 100 are not.'); + expect(serialized).not.toContain('[97]'); + expect(serialized).toContain( + '[assistant]\nThe subagent says only 97 is prime.' + ); + }); + + it('serializes subagent parts and thinking on request, labeled', () => { + const serialized = serializeTranscript(subagentAdapted.transcript, { + includeSubagents: true, + includeThinking: true, + }); + expect(serialized).toContain( + '[subagent:general-purpose thinking]\n97 is prime; 91 and 100 are not.' + ); + expect(serialized).toContain('[subagent:general-purpose assistant]\n[97]'); + }); + }); + it('renders a scorer-facing transcript (messages + tool calls, raw args preserved)', () => { expect(adapted.transcript).toEqual([ { type: 'message', role: 'assistant', content: 'Let me list the files.' }, diff --git a/packages/core/src/agents/claude-code/parser.ts b/packages/core/src/agents/claude-code/parser.ts index 33ac175b..31c21ce4 100644 --- a/packages/core/src/agents/claude-code/parser.ts +++ b/packages/core/src/agents/claude-code/parser.ts @@ -8,11 +8,19 @@ * tool results arrive on `user` lines as `tool_result` content blocks, and the * run ends with a top-level `result` line carrying the final text. * + * With `--forward-subagent-text` (set by the runner), subagent text/thinking + * arrive as ordinary `assistant`/`user` lines whose top-level + * `parent_tool_use_id` names the spawning Agent/Task `tool_use` id, alongside + * `subagent_type` and `task_description`. Those events are tagged with the + * agnostic `subagent` ref so downstream consumers keep them off the main + * thread. + * * Adapted from `@supabase/agent-evals` (packages/agent-eval/src/parsers). */ import type { ParsedTranscript, + SubagentRef, TranscriptEvent, } from '../../transcript/types.js'; import type { AgentTranscriptParser } from '../../parsers/types.js'; @@ -47,8 +55,9 @@ const CLAUDE_CODE_TOOLS: AgentToolMap = { Glob: 'glob', Grep: 'grep', LS: 'list_dir', - // Agent / subagent + // Agent / subagent (`Task` was renamed `Agent` in Claude Code 2.1.2xx) Task: 'agent_task', + Agent: 'agent_task', TodoWrite: 'agent_task', }, }; @@ -164,11 +173,29 @@ function loadedSkillFromClaudeCodeCall( return undefined; } +/** + * Subagent attribution of a stream-json line: present when the line was + * forwarded from inside a Task/Agent subagent (`--forward-subagent-text`). + */ +function subagentRef(data: Record): SubagentRef | undefined { + if (typeof data.parent_tool_use_id !== 'string') return undefined; + return { + id: data.parent_tool_use_id, + ...(typeof data.subagent_type === 'string' + ? { type: data.subagent_type } + : {}), + ...(typeof data.task_description === 'string' + ? { description: data.task_description } + : {}), + }; +} + function recordToEvents(data: Record): TranscriptEvent[] { const events: TranscriptEvent[] = []; const timestamp = typeof data.timestamp === 'string' ? data.timestamp : undefined; const type = data.type; + const subagent = subagentRef(data); if (type === 'user' || data.role === 'user') { const toolResults = getContentArray(data)?.filter( @@ -272,6 +299,9 @@ function recordToEvents(data: Record): TranscriptEvent[] { }); } + if (subagent) { + for (const event of events) event.subagent = subagent; + } return events; } @@ -291,8 +321,12 @@ export const claudeCodeParser: AgentTranscriptParser = { for (const record of records) { try { for (const event of recordToEvents(record)) { + // Track only main-thread assistant text: the terminal `result` line + // repeats the main thread's final message, never a subagent's. const isAssistantMessage = - event.type === 'message' && event.role === 'assistant'; + event.type === 'message' && + event.role === 'assistant' && + !event.subagent; // The terminal `result` line repeats the final assistant message that // the preceding `assistant` line already emitted (stream-json carries // both). Drop the duplicate, but still emit it when the text was never diff --git a/packages/core/src/agents/claude-code/runner.ts b/packages/core/src/agents/claude-code/runner.ts index 37129ca1..36702e08 100644 --- a/packages/core/src/agents/claude-code/runner.ts +++ b/packages/core/src/agents/claude-code/runner.ts @@ -24,8 +24,10 @@ export const claudeCodeRunner: AgentRunner = { apiKeyEnvVar: 'ANTHROPIC_API_KEY', cliPackage: '@anthropic-ai/claude-code', // Pinned: Claude Code's transcript format evolves; bump deliberately and - // re-check the parser. See ./parser.ts. - defaultCliVersion: '2.1.191', + // re-check the parser. See ./parser.ts. 2.1.220 is the minimum for + // `--forward-subagent-text` (added in 2.1.212) and renames the Task tool + // to Agent. + defaultCliVersion: '2.1.220', defaultModel: 'claude-sonnet-4-6', async install(sandbox, version) { @@ -65,6 +67,13 @@ export const claudeCodeRunner: AgentRunner = { // Newline-delimited JSON events on stdout (requires --verbose). '--output-format stream-json', '--verbose', + // Forward subagent text/thinking as lines tagged with + // `parent_tool_use_id` (see ./parser.ts), and request summarized + // thinking text — Opus 5-era models stream empty thinking blocks + // otherwise. Both are visibility-only: they change what the transcript + // captures, not how the agent behaves. + '--forward-subagent-text', + '--thinking-display summarized', `--model ${shellQuote(model)}`, // Reasoning effort for the session; omitted leaves Claude Code's default. ...(reasoningEffort ? [`--effort ${shellQuote(reasoningEffort)}`] : []), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index caa480d2..e66268a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import type { ToolName } from './transcript/types.js'; +import type { SubagentRef, ToolName } from './transcript/types.js'; import { createClient, type SupabaseClient } from '@supabase/supabase-js'; import { createMCPClient } from '@ai-sdk/mcp'; import { Experimental_StdioMCPTransport as StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'; @@ -121,6 +121,7 @@ export type { ToolName, TranscriptEvent, ParsedTranscript, + SubagentRef, } from './transcript/types.js'; export type { AgentHarnessId, @@ -153,6 +154,13 @@ export type TranscriptPart = type: 'message'; role: 'system' | 'user' | 'assistant'; content: string; + /** Set when the part came from a delegated subagent, not the main thread. */ + subagent?: SubagentRef; + } + | { + type: 'thinking'; + content: string; + subagent?: SubagentRef; } | { type: 'tool_call'; @@ -160,11 +168,22 @@ export type TranscriptPart = input: Record; output?: unknown; error?: string; + subagent?: SubagentRef; }; export type TranscriptSerializationOptions = { includeToolCallInputs?: boolean; includeToolCallOutputs?: boolean; + /** + * Include `thinking` parts. Off by default so existing judge inputs are + * unchanged by transcripts that now capture thinking. + */ + includeThinking?: boolean; + /** + * Include subagent-attributed parts. Off by default for the same reason: + * the main thread reads exactly as it did before subagent forwarding. + */ + includeSubagents?: boolean; }; export interface JudgeInput { @@ -531,12 +550,27 @@ export function serializeTranscript( options: TranscriptSerializationOptions = {} ): string { const parts = transcript.flatMap((event) => { + // Subagent-attributed and thinking parts are captured for visibility but + // serialized only on request, so judge inputs written before subagent + // forwarding read the same after it. + if (event.subagent && !options.includeSubagents) return []; + const label = (base: string) => + event.subagent + ? `[subagent${event.subagent.type ? `:${event.subagent.type}` : ''} ${base}]` + : `[${base}]`; + if (event.type === 'message') { const content = event.content.trim(); - return content ? [`[${event.role}]\n${content}`] : []; + return content ? [`${label(event.role)}\n${content}`] : []; + } + + if (event.type === 'thinking') { + if (!options.includeThinking) return []; + const content = event.content.trim(); + return content ? [`${label('thinking')}\n${content}`] : []; } - const lines = [`[called ${event.name}]`]; + const lines = [label(`called ${event.name}`)]; if (options.includeToolCallInputs) { lines.push(`input:\n${JSON.stringify(event.input, null, 2)}`); } diff --git a/packages/core/src/parsers/adapt.ts b/packages/core/src/parsers/adapt.ts index 89e35b7a..fc7fc27e 100644 --- a/packages/core/src/parsers/adapt.ts +++ b/packages/core/src/parsers/adapt.ts @@ -43,14 +43,31 @@ export function adaptTranscript(events: TranscriptEvent[]): AdaptedTranscript { let steps = 0; for (const event of events) { + // Subagent-attributed events are kept in the transcript (tagged) for + // visibility, but never shape the main-thread surface: the final report, + // the step count, and the scorer-facing tool-call records stay exactly + // what the top-level agent did. if (event.type === 'message' && event.role && event.content) { const content = event.content.trim(); if (!content) continue; - transcript.push({ type: 'message', role: event.role, content }); - if (event.role === 'assistant') { + transcript.push({ + type: 'message', + role: event.role, + content, + ...(event.subagent ? { subagent: event.subagent } : {}), + }); + if (event.role === 'assistant' && !event.subagent) { agentReport = content; steps += 1; } + } else if (event.type === 'thinking' && event.content) { + const content = event.content.trim(); + if (!content) continue; + transcript.push({ + type: 'thinking', + content, + ...(event.subagent ? { subagent: event.subagent } : {}), + }); } else if (event.type === 'tool_call' && event.tool) { const body = event.tool.args ?? {}; const resolved = event.tool.id @@ -62,7 +79,9 @@ export function adaptTranscript(events: TranscriptEvent[]): AdaptedTranscript { input: body, output: resolved?.error === undefined ? resolved?.result : undefined, error: resolved?.error, + ...(event.subagent ? { subagent: event.subagent } : {}), }); + if (event.subagent) continue; toolCalls.push({ endpoint: event.tool.originalName, body, diff --git a/packages/core/src/transcript/types.ts b/packages/core/src/transcript/types.ts index 5a939213..8a8b355f 100644 --- a/packages/core/src/transcript/types.ts +++ b/packages/core/src/transcript/types.ts @@ -28,12 +28,29 @@ export type ToolName = | 'tool_use' | 'unknown'; +/** + * Reference to the delegated subagent an event belongs to. Agent-agnostic: + * any harness that delegates work (Claude Code's Task/Agent tool, …) can + * populate it; consumers use it to keep subagent activity distinct from the + * main thread (e.g. the final report and step count). + */ +export interface SubagentRef { + /** Correlation id of the spawning `agent_task` tool call (its `tool.id`). */ + id?: string; + /** Subagent kind as the harness names it (e.g. "general-purpose"). */ + type?: string; + /** Task description the parent gave the subagent. */ + description?: string; +} + /** A single normalized event in an agent transcript. */ export interface TranscriptEvent { /** ISO timestamp of the event, when the agent records one. */ timestamp?: string; /** Event kind. */ type: 'message' | 'tool_call' | 'tool_result' | 'thinking' | 'error'; + /** Set when the event happened inside a delegated subagent, not the main thread. */ + subagent?: SubagentRef; /** For `message` events: the speaker. */ role?: 'user' | 'assistant' | 'system'; /** Text content (for `message`, `thinking`, `error`). */ From c16c8deb30661208925638dc0568b49c4e967802 Mon Sep 17 00:00:00 2001 From: Pedro Rodrigues Date: Tue, 28 Jul 2026 16:52:34 +0100 Subject: [PATCH 2/2] style: biome formatting in claude-code parser tests Co-Authored-By: Claude Fable 5 --- packages/core/src/agents/claude-code/parser.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agents/claude-code/parser.test.ts b/packages/core/src/agents/claude-code/parser.test.ts index f89baea4..06f09731 100644 --- a/packages/core/src/agents/claude-code/parser.test.ts +++ b/packages/core/src/agents/claude-code/parser.test.ts @@ -355,9 +355,9 @@ describe('adaptTranscript', () => { 'thinking', 'message', ]); - expect( - tagged.find((p) => p.type === 'thinking')?.content - ).toBe('97 is prime; 91 and 100 are not.'); + expect(tagged.find((p) => p.type === 'thinking')?.content).toBe( + '97 is prime; 91 and 100 are not.' + ); }); it('serializes identically to a subagent-free transcript by default', () => { @@ -377,7 +377,9 @@ describe('adaptTranscript', () => { expect(serialized).toContain( '[subagent:general-purpose thinking]\n97 is prime; 91 and 100 are not.' ); - expect(serialized).toContain('[subagent:general-purpose assistant]\n[97]'); + expect(serialized).toContain( + '[subagent:general-purpose assistant]\n[97]' + ); }); });