Skip to content

Commit c193943

Browse files
committed
Add read_agent_trace fleet verb for orchestrator tiers
Lets an orchestrator or nested orchestrator read a worker's on-disk turns.jsonl directly, so a cancelled or interrupted worker's completed work is no longer invisible once its in-memory session record is gone. Every response is bounded (turn window, entry count, per-entry chars), tolerates partially written/malformed lines, and reports a clean error for an unknown target. Fixes a latest-symlink double-count in directory enumeration by resolving symlinks and de-duping by real path. progress_note for leaf workers is a separate follow-up, not included here.
1 parent 8af97ac commit c193943

8 files changed

Lines changed: 809 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,22 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3939
own), so the two layers no longer multiply. Attempt counts are now logged
4040
on each recovery so retry storms are visible in traces.
4141

42+
- **`read_agent_trace` lets an orchestrator inspect a worker's on-disk trace
43+
directly**, so a cancelled or interrupted worker's completed work is no
44+
longer invisible just because its in-memory session record is gone. Reads
45+
turns, tool calls, and tool errors straight from the worker's
46+
`turns.jsonl`, tolerating a partially written or malformed line without
47+
failing. Every response is bounded on four independent axes — turn window,
48+
entry count, per-entry characters, and total output characters (the first
49+
three multiply, so a total-output ceiling caps them together) — each with
50+
a hard maximum the caller cannot exceed, and a truncated response says
51+
exactly what was left out and how to page for the rest. A Tier 2 nested
52+
orchestrator can only read its own descendants' traces, enforced by
53+
reusing `SubAgentSessionStore`'s existing parentSessionId chain
54+
(`assertCanTargetAgent`'s first live call site); leaf directors never see
55+
the tool at all. `progress_note` for leaf workers is a separate,
56+
not-yet-implemented follow-up.
57+
4258
### Fixed
4359

4460
- **Interrupting a turn no longer risks a startup crash.** If an interrupt hit

src/agent/tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { createWebSearchTool, disposeWebSearchClients } from "../tools/web-searc
4646
import { createUseSkillTool } from "./use-skill.js";
4747
import { createToolIndex, createToolSearchTool } from "./tool-search.js";
4848
import { createSearchAgentsTool } from "./agent-search.js";
49+
import { createReadAgentTraceTool } from "../subagent/trace-tool.js";
4950
import {
5051
createCodexToolProxies,
5152
type CodexRunManageTasks,
@@ -309,6 +310,9 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
309310
}),
310311
]
311312
: []),
313+
// Tier 1: the primary session is always an orchestrator, so this is
314+
// unconditional wherever task/search_agents are mounted.
315+
createReadAgentTraceTool(args.subAgent.getWorkdirBase),
312316
]
313317
: []),
314318
stringTool({

src/subagent/authority.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
* in a prompt. This module owns two checks:
66
*
77
* - assertTierMayMountFleetVerb: a Tier 3 leaf may never mount a fleet verb
8-
* (today: task, search_agents; the spawn_agent/wait_agents/list_agents/
9-
* send_input/interrupt_agent/close_agent/resume_agent/read_agent_trace
10-
* verbs land in later child issues against this same gate).
8+
* (today: task, search_agents, read_agent_trace; the spawn_agent/
9+
* wait_agents/list_agents/send_input/interrupt_agent/close_agent/
10+
* resume_agent/followup_task verbs land in later child issues against
11+
* this same gate).
1112
* - assertCanTargetAgent: a Tier 2 nested orchestrator may act only on its
1213
* own descendants, never a sibling or anything above it in the tree.
1314
* Tier 1 (the primary orchestrator) may target anyone. Callers pass the

src/subagent/run.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
105105
} from "./stop-policy.js";
106106
import { SubAgentDirector } from "./nudge-director.js";
107107
import { assertTierMayMountFleetVerb } from "./authority.js";
108+
import { createReadAgentTraceTool } from "./trace-tool.js";
108109
import {
109110
abortError,
110111
createSubAgentSpawnRegistryPlugin,
@@ -435,7 +436,7 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
435436
// AgentProfile with orchestrator: true from mounting task/search_agents
436437
// just because it is outside the closed director set.
437438
const tier = params.orchestratorTier ?? "leaf";
438-
for (const verb of ["task", "search_agents"]) {
439+
for (const verb of ["task", "search_agents", "read_agent_trace"]) {
439440
assertTierMayMountFleetVerb(tier, verb);
440441
}
441442
if (params.nestedDispatch === undefined) {
@@ -481,6 +482,11 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
481482
}),
482483
]
483484
: []),
485+
// Every worker at every nesting depth is created under the same root
486+
// workdirBase (nestedDispatch.getWorkdirBase is threaded through
487+
// unchanged, never rebound to this worker's own dir), so the trace
488+
// reader's search root is that same function.
489+
createReadAgentTraceTool(nd.getWorkdirBase),
484490
];
485491
}
486492

src/subagent/trace-reader.test.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { describe, test, expect } from "bun:test";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
6+
import {
7+
AgentTraceNotFoundError,
8+
findAgentTraceDir,
9+
listUniqueSubdirs,
10+
readAgentTrace,
11+
MAX_TRACE_ENTRY_LIMIT,
12+
MAX_TRACE_TURN_WINDOW,
13+
} from "./trace-reader.js";
14+
15+
function tempDir(): string {
16+
return fs.mkdtempSync(path.join(os.tmpdir(), "trace-reader-"));
17+
}
18+
19+
function writeTurns(dir: string, turns: unknown[]): void {
20+
fs.mkdirSync(dir, { recursive: true });
21+
const text = turns.map((t) => JSON.stringify(t)).join("\n") + (turns.length > 0 ? "\n" : "");
22+
fs.writeFileSync(path.join(dir, "turns.jsonl"), text);
23+
}
24+
25+
describe("listUniqueSubdirs", () => {
26+
test("a directory containing latest plus its target enumerates the session exactly once", async () => {
27+
const root = tempDir();
28+
const real = path.join(root, "01234567-89ab-7def-8123-456789abcdef");
29+
fs.mkdirSync(real);
30+
fs.symlinkSync(path.basename(real), path.join(root, "latest"));
31+
32+
const entries = await listUniqueSubdirs(root);
33+
expect(entries).toHaveLength(1);
34+
expect(entries[0]!.path).toBe(fs.realpathSync(real));
35+
});
36+
37+
test("two distinct real directories are both listed", async () => {
38+
const root = tempDir();
39+
fs.mkdirSync(path.join(root, "a"));
40+
fs.mkdirSync(path.join(root, "b"));
41+
const entries = await listUniqueSubdirs(root);
42+
expect(entries).toHaveLength(2);
43+
});
44+
45+
test("a broken symlink is skipped, not thrown", async () => {
46+
const root = tempDir();
47+
fs.symlinkSync(path.join(root, "does-not-exist"), path.join(root, "dangling"));
48+
const entries = await listUniqueSubdirs(root);
49+
expect(entries).toHaveLength(0);
50+
});
51+
52+
test("missing directory returns empty rather than throwing", async () => {
53+
const entries = await listUniqueSubdirs(path.join(tempDir(), "nope"));
54+
expect(entries).toHaveLength(0);
55+
});
56+
});
57+
58+
describe("findAgentTraceDir", () => {
59+
test("finds a direct child under root/subagents", async () => {
60+
const root = tempDir();
61+
const childDir = path.join(root, "subagents", "child-1");
62+
writeTurns(childDir, []);
63+
const found = await findAgentTraceDir(root, "child-1");
64+
expect(found).toBe(fs.realpathSync(childDir));
65+
});
66+
67+
test("finds a nested descendant several levels deep", async () => {
68+
const root = tempDir();
69+
const grandchildDir = path.join(root, "subagents", "child-1", "subagents", "grandchild-1");
70+
writeTurns(grandchildDir, []);
71+
const found = await findAgentTraceDir(root, "grandchild-1");
72+
expect(found).toBe(fs.realpathSync(grandchildDir));
73+
});
74+
75+
test("returns null for an unknown id", async () => {
76+
const root = tempDir();
77+
writeTurns(path.join(root, "subagents", "child-1"), []);
78+
const found = await findAgentTraceDir(root, "does-not-exist");
79+
expect(found).toBeNull();
80+
});
81+
82+
test("is not confused by a latest symlink alongside the real worker dir", async () => {
83+
const root = tempDir();
84+
const childDir = path.join(root, "subagents", "child-1");
85+
writeTurns(childDir, []);
86+
fs.symlinkSync("child-1", path.join(root, "subagents", "latest"));
87+
const found = await findAgentTraceDir(root, "child-1");
88+
expect(found).toBe(fs.realpathSync(childDir));
89+
});
90+
});
91+
92+
describe("readAgentTrace", () => {
93+
test("throws a clean error for a missing target", async () => {
94+
const root = tempDir();
95+
await expect(readAgentTrace(root, "ghost")).rejects.toBeInstanceOf(AgentTraceNotFoundError);
96+
});
97+
98+
test("reads turns, tool calls, and tool errors", async () => {
99+
const root = tempDir();
100+
const childDir = path.join(root, "subagents", "worker-1");
101+
writeTurns(childDir, [
102+
{ role: "user", content: [{ type: "text", text: "do the thing" }] },
103+
{
104+
role: "assistant",
105+
content: [{ type: "tool_call", id: "call-1", name: "run_shell", arguments: { cmd: "ls" } }],
106+
},
107+
{
108+
role: "user",
109+
content: [
110+
{
111+
type: "tool_result",
112+
callId: "call-1",
113+
content: [{ type: "text", text: "boom" }],
114+
isError: true,
115+
},
116+
],
117+
},
118+
]);
119+
120+
const result = await readAgentTrace(root, "worker-1");
121+
expect(result.totalTurns).toBe(3);
122+
expect(result.entries.map((e) => e.kind)).toEqual(["text", "tool_call", "error"]);
123+
expect(result.entries[2]!.isError).toBe(true);
124+
expect(result.omitted).toBeNull();
125+
});
126+
127+
test("skips a malformed trailing line instead of throwing", async () => {
128+
const root = tempDir();
129+
const childDir = path.join(root, "subagents", "worker-1");
130+
fs.mkdirSync(childDir, { recursive: true });
131+
const good = JSON.stringify({ role: "user", content: [{ type: "text", text: "hi" }] });
132+
fs.writeFileSync(path.join(childDir, "turns.jsonl"), `${good}\n{"role":"assistant","cont`);
133+
134+
const result = await readAgentTrace(root, "worker-1");
135+
expect(result.totalTurns).toBe(1);
136+
expect(result.parseWarnings).toBe(1);
137+
expect(result.entries).toHaveLength(1);
138+
});
139+
140+
test("bounds the entry count to the requested limit and reports omission", async () => {
141+
const root = tempDir();
142+
const childDir = path.join(root, "subagents", "worker-1");
143+
const turns = Array.from({ length: 5 }, (_, i) => ({
144+
role: "assistant",
145+
content: [{ type: "text", text: `turn ${i}` }],
146+
}));
147+
writeTurns(childDir, turns);
148+
149+
const result = await readAgentTrace(root, "worker-1", { limit: 2 });
150+
expect(result.entries).toHaveLength(2);
151+
expect(result.entriesTruncated).toBe(true);
152+
expect(result.omitted).not.toBeNull();
153+
expect(result.omitted!.hint.length).toBeGreaterThan(0);
154+
});
155+
156+
test("never exceeds the hard entry-limit cap regardless of requested limit", async () => {
157+
const root = tempDir();
158+
const childDir = path.join(root, "subagents", "worker-1");
159+
const turns = Array.from({ length: 10 }, (_, i) => ({
160+
role: "assistant",
161+
content: [{ type: "text", text: `turn ${i}` }],
162+
}));
163+
writeTurns(childDir, turns);
164+
165+
const result = await readAgentTrace(root, "worker-1", { limit: 1_000_000 });
166+
expect(result.entries.length).toBeLessThanOrEqual(MAX_TRACE_ENTRY_LIMIT);
167+
});
168+
169+
test("never exceeds the hard turn-window cap regardless of requested range", async () => {
170+
const root = tempDir();
171+
const childDir = path.join(root, "subagents", "worker-1");
172+
const turns = Array.from({ length: 500 }, (_, i) => ({
173+
role: "assistant",
174+
content: [{ type: "text", text: `turn ${i}` }],
175+
}));
176+
writeTurns(childDir, turns);
177+
178+
const result = await readAgentTrace(root, "worker-1", {
179+
fromTurn: 0,
180+
toTurn: 500,
181+
limit: MAX_TRACE_ENTRY_LIMIT,
182+
});
183+
expect(result.toTurn - result.fromTurn).toBeLessThanOrEqual(MAX_TRACE_TURN_WINDOW);
184+
});
185+
186+
test("filters entries by kind", async () => {
187+
const root = tempDir();
188+
const childDir = path.join(root, "subagents", "worker-1");
189+
writeTurns(childDir, [
190+
{
191+
role: "assistant",
192+
content: [
193+
{ type: "thinking", thinking: "hmm" },
194+
{ type: "text", text: "hello" },
195+
],
196+
},
197+
]);
198+
199+
const result = await readAgentTrace(root, "worker-1", { kinds: ["text"] });
200+
expect(result.entries.map((e) => e.kind)).toEqual(["text"]);
201+
});
202+
203+
test("truncates an oversized entry body and marks it truncated", async () => {
204+
const root = tempDir();
205+
const childDir = path.join(root, "subagents", "worker-1");
206+
writeTurns(childDir, [
207+
{ role: "assistant", content: [{ type: "text", text: "x".repeat(10_000) }] },
208+
]);
209+
210+
const result = await readAgentTrace(root, "worker-1");
211+
expect(result.entries[0]!.truncated).toBe(true);
212+
expect(result.entries[0]!.content.length).toBeLessThan(10_000);
213+
});
214+
215+
test("a partially written trace (worker still running) reads what exists so far", async () => {
216+
const root = tempDir();
217+
const childDir = path.join(root, "subagents", "worker-1");
218+
fs.mkdirSync(childDir, { recursive: true });
219+
fs.writeFileSync(
220+
path.join(childDir, "turns.jsonl"),
221+
`${JSON.stringify({ role: "user", content: [{ type: "text", text: "go" }] })}\n`,
222+
);
223+
224+
const result = await readAgentTrace(root, "worker-1");
225+
expect(result.totalTurns).toBe(1);
226+
expect(result.entries).toHaveLength(1);
227+
});
228+
});

0 commit comments

Comments
 (0)