Skip to content

Commit 94bb304

Browse files
committed
feat(telemetry): report spawn setup_ms, tool family buckets, hydrate_ms
1 parent 4a03fb5 commit 94bb304

9 files changed

Lines changed: 486 additions & 3 deletions

File tree

‎src/subagent/agent-fleet.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
11181118
const parentTraceId = getCurrentTurnTraceId();
11191119
telemetry.capture("subagent_start", { agent_name: agentName });
11201120
const startedAt = Date.now();
1121+
let setupEndMs: number | undefined;
11211122
let settlement: Readonly<SubAgentRunSettlement> | undefined;
11221123
let endFinalized = false;
11231124
let runInterrupted = false;
@@ -1136,6 +1137,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
11361137
agentName,
11371138
status: setupFailed ? "failed" : status,
11381139
durationMs: Date.now() - startedAt,
1140+
setupMs: (setupEndMs ?? Date.now()) - startedAt,
11391141
model: settlement?.model ?? provider.model,
11401142
stopReason: setupFailed
11411143
? "setup_error"
@@ -1149,6 +1151,13 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
11491151
reasoning_tokens: 0,
11501152
tool_call_count: 0,
11511153
tool_error_count: 0,
1154+
tool_read_count: 0,
1155+
tool_write_count: 0,
1156+
tool_shell_count: 0,
1157+
tool_search_count: 0,
1158+
tool_agent_count: 0,
1159+
tool_other_count: 0,
1160+
hydrate_ms: 0,
11521161
},
11531162
...(parentTraceId !== undefined ? { parentTraceId } : {}),
11541163
});
@@ -1503,6 +1512,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
15031512

15041513
// Fire and forget: this handler must return before the worker finishes.
15051514
// Wait JSON projects stored WorkerLifecycle plus the per-install overlay.
1515+
// Setup (worktree snapshot, admission, lane checks) ends here; the
1516+
// worker run owns the remaining duration.
1517+
setupEndMs = Date.now();
15061518
deps
15071519
.run(params)
15081520
.then((result) => {

‎src/subagent/index.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type {
3636
ReactorState,
3737
} from "@intx/types/runtime";
3838
import { defined } from "../../tests/helpers/defined.js";
39+
import { classifyToolFamily } from "./run.js";
3940

4041
describe("sub-agent teardown", () => {
4142
test("disposeSubAgentSession closes agent, awaits stream, and disposes posix tools once", async () => {
@@ -1424,3 +1425,30 @@ describe("buildDispatchBrief typed spawn contract", () => {
14241425
expect(both).toContain("1. manage_tasks seed");
14251426
});
14261427
});
1428+
1429+
describe("classifyToolFamily", () => {
1430+
test("maps canonical engine names onto the six telemetry buckets", () => {
1431+
expect(classifyToolFamily("read_file")).toBe("read");
1432+
expect(classifyToolFamily("write_file")).toBe("write");
1433+
expect(classifyToolFamily("edit_file")).toBe("write");
1434+
expect(classifyToolFamily("delete_file")).toBe("write");
1435+
expect(classifyToolFamily("run_shell")).toBe("shell");
1436+
expect(classifyToolFamily("shell_collect")).toBe("shell");
1437+
expect(classifyToolFamily("grep")).toBe("search");
1438+
expect(classifyToolFamily("search_files")).toBe("search");
1439+
expect(classifyToolFamily("search_agents")).toBe("search");
1440+
expect(classifyToolFamily("spawn_agent")).toBe("agent");
1441+
expect(classifyToolFamily("wait_agents")).toBe("agent");
1442+
expect(classifyToolFamily("ask_director")).toBe("agent");
1443+
expect(classifyToolFamily("submit_result")).toBe("agent");
1444+
});
1445+
1446+
test("falls back to other for orchestration, web, and unknown tools", () => {
1447+
expect(classifyToolFamily("manage_tasks")).toBe("other");
1448+
expect(classifyToolFamily("web_fetch")).toBe("other");
1449+
expect(classifyToolFamily("web_search")).toBe("other");
1450+
expect(classifyToolFamily("mcp__plugin_tool")).toBe("other");
1451+
expect(classifyToolFamily("something_unknown")).toBe("other");
1452+
expect(classifyToolFamily("")).toBe("other");
1453+
});
1454+
});

‎src/subagent/run-settlement.test.ts‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

66
import type { ReactorEmittedEvent } from "@intx/inference";
7+
import type {
8+
ContextTransform,
9+
ConversationTurn,
10+
StrategyContext,
11+
} from "@intx/types/runtime";
712

813
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
14+
import { defined } from "../../tests/helpers/defined.js";
915
import { createPermissionGate } from "../permission/gate.js";
1016
import type { SubAgentRunSettlement } from "./types.js";
1117

@@ -193,3 +199,202 @@ test("pre-progress cancellation settles as cancelled without changing rejection"
193199
});
194200
expect(Object.isFrozen(settlement)).toBe(true);
195201
});
202+
203+
test("tool calls accumulate into family buckets whose sum equals tool_call_count", async () => {
204+
const cwd = await mkdtemp(join(tmpdir(), "corbits-run-buckets-"));
205+
const originalError = new Error("bucket probe done");
206+
let settlement: Readonly<SubAgentRunSettlement> | undefined;
207+
const toolNames = [
208+
"read_file",
209+
"read_file",
210+
"write_file",
211+
"run_shell",
212+
"grep",
213+
"spawn_agent",
214+
"manage_tasks",
215+
"mystery_tool",
216+
];
217+
218+
const caught = await withMockedModuleDuring(
219+
import.meta.resolve("../agent/live-tool-dispatch.js"),
220+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
221+
...real,
222+
createAgentWithLiveToolDispatch: async () => ({
223+
send: async () => {
224+
await new Promise((resolve) => setTimeout(resolve, 20));
225+
throw originalError;
226+
},
227+
stream: () =>
228+
(async function* (): AsyncGenerator<ReactorEmittedEvent> {
229+
for (const [index, name] of toolNames.entries()) {
230+
yield {
231+
type: "tool.start",
232+
seq: index + 1,
233+
data: {
234+
call: { id: `call-${index}`, name, arguments: {} },
235+
},
236+
} as ReactorEmittedEvent;
237+
}
238+
})(),
239+
deliver: () => undefined,
240+
close: async () => undefined,
241+
setSource: () => undefined,
242+
setSources: () => undefined,
243+
history: async () => [],
244+
checkpoints: async () => [],
245+
readAt: async () => [],
246+
blobReader: {},
247+
}),
248+
}),
249+
async () => {
250+
const { runSubAgent } = await import("./run.js");
251+
try {
252+
await runSubAgent({
253+
cwd,
254+
workdirBase: join(cwd, ".ctx"),
255+
permissionGate,
256+
provider: {
257+
providerName: "initial",
258+
baseURL: "http://localhost",
259+
model: "initial-model",
260+
},
261+
description: "bucket probe",
262+
prompt: "use tools across families",
263+
onRunSettled: (summary) => {
264+
settlement = summary;
265+
},
266+
});
267+
} catch (error) {
268+
return error;
269+
}
270+
throw new Error("expected runSubAgent to reject");
271+
},
272+
);
273+
274+
expect(caught).toBe(originalError);
275+
expect(settlement).toMatchObject({
276+
tool_call_count: 8,
277+
tool_read_count: 2,
278+
tool_write_count: 1,
279+
tool_shell_count: 1,
280+
tool_search_count: 1,
281+
tool_agent_count: 1,
282+
tool_other_count: 2,
283+
hydrate_ms: 0,
284+
});
285+
const settled = defined(settlement);
286+
expect(settled.tool_call_count).toBe(
287+
settled.tool_read_count +
288+
settled.tool_write_count +
289+
settled.tool_shell_count +
290+
settled.tool_search_count +
291+
settled.tool_agent_count +
292+
settled.tool_other_count,
293+
);
294+
});
295+
296+
async function runWithRehydrateDelay(
297+
delayMs: number,
298+
): Promise<Readonly<SubAgentRunSettlement> | undefined> {
299+
const cwd = await mkdtemp(join(tmpdir(), "corbits-run-hydrate-"));
300+
const originalError = new Error("hydrate probe done");
301+
let settlement: Readonly<SubAgentRunSettlement> | undefined;
302+
let captured: ContextTransform[] | undefined;
303+
const testCtx = {} as StrategyContext;
304+
305+
await withMockedModuleDuring(
306+
import.meta.resolve("../session/attachment-store.js"),
307+
(real: typeof import("../session/attachment-store.js")) => ({
308+
...real,
309+
createAttachmentRehydrateTransform: () => ({
310+
name: "attachment-rehydrate",
311+
version: "1",
312+
apply: async (turns: ConversationTurn[]) => {
313+
if (delayMs > 0) {
314+
await new Promise((resolve) => setTimeout(resolve, delayMs));
315+
}
316+
return {
317+
output: turns,
318+
record: {
319+
strategy: "attachment-rehydrate",
320+
version: "1",
321+
parameters: {},
322+
reason: "noop",
323+
decisions: {},
324+
},
325+
};
326+
},
327+
}),
328+
}),
329+
async () =>
330+
withMockedModuleDuring(
331+
import.meta.resolve("../agent/live-tool-dispatch.js"),
332+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
333+
...real,
334+
createAgentWithLiveToolDispatch: async (...args: unknown[]) => {
335+
const env = args[1] as
336+
| { deps?: { contextTransforms?: ContextTransform[] } }
337+
| undefined;
338+
captured = env?.deps?.contextTransforms;
339+
return {
340+
send: async () => {
341+
const rehydrate = captured?.find(
342+
(transform) => transform.name === "attachment-rehydrate",
343+
);
344+
await rehydrate?.apply([], testCtx);
345+
throw originalError;
346+
},
347+
stream: () =>
348+
(async function* (): AsyncGenerator<ReactorEmittedEvent> {
349+
yield* [];
350+
})(),
351+
deliver: () => undefined,
352+
close: async () => undefined,
353+
setSource: () => undefined,
354+
setSources: () => undefined,
355+
history: async () => [],
356+
checkpoints: async () => [],
357+
readAt: async () => [],
358+
blobReader: {},
359+
} as unknown as Awaited<
360+
ReturnType<typeof real.createAgentWithLiveToolDispatch>
361+
>;
362+
},
363+
}),
364+
async () => {
365+
const { runSubAgent } = await import("./run.js");
366+
try {
367+
await runSubAgent({
368+
cwd,
369+
workdirBase: join(cwd, ".ctx"),
370+
permissionGate,
371+
provider: {
372+
providerName: "initial",
373+
baseURL: "http://localhost",
374+
model: "initial-model",
375+
},
376+
description: "hydrate probe",
377+
prompt: "rehydrate attachments",
378+
onRunSettled: (summary) => {
379+
settlement = summary;
380+
},
381+
});
382+
} catch {
383+
return;
384+
}
385+
throw new Error("expected runSubAgent to reject");
386+
},
387+
),
388+
);
389+
return settlement;
390+
}
391+
392+
test("a slow attachment rehydrate accumulates into hydrate_ms", async () => {
393+
const settlement = await runWithRehydrateDelay(60);
394+
expect(defined(settlement).hydrate_ms).toBeGreaterThanOrEqual(50);
395+
});
396+
397+
test("a noop attachment rehydrate reports hydrate_ms zero", async () => {
398+
const settlement = await runWithRehydrateDelay(0);
399+
expect(defined(settlement).hydrate_ms).toBe(0);
400+
});

0 commit comments

Comments
 (0)