Skip to content

Commit e6cb5d9

Browse files
committed
Add permission.wait and subagent spans for attribution
1 parent ddb9c72 commit e6cb5d9

7 files changed

Lines changed: 337 additions & 2 deletions

File tree

src/perf/index.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ describe("sanitizeTags", () => {
243243
provider_id: "openai",
244244
model_id: "gpt-5.4",
245245
transport: "ws",
246+
decision: "allow",
246247
duration_ms: 12.5,
247248
bytes: 1024,
248249
payload_bytes: 2048,
@@ -257,6 +258,7 @@ describe("sanitizeTags", () => {
257258
provider_id: "openai",
258259
model_id: "gpt-5.4",
259260
transport: "ws",
261+
decision: "allow",
260262
duration_ms: 12.5,
261263
bytes: 1024,
262264
payload_bytes: 2048,
@@ -269,6 +271,12 @@ describe("sanitizeTags", () => {
269271
});
270272
});
271273

274+
test("keeps decision allow/deny and strips free-text decisions", () => {
275+
expect(sanitizeTags({ decision: "allow" })).toEqual({ decision: "allow" });
276+
expect(sanitizeTags({ decision: "deny" })).toEqual({ decision: "deny" });
277+
expect(sanitizeTags({ decision: "maybe" })).toBeUndefined();
278+
});
279+
272280
test("strips free-text, paths, prompts, and unknown keys", () => {
273281
const tags = sanitizeTags({
274282
prompt: "system: you are a helpful assistant",

src/perf/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {
1818
OPAQUE_ID_RE,
1919
ALLOWED_TAG_KEYS,
2020
type AllowedTagKey,
21+
type DecisionKind,
2122
type PerfTags,
2223
type TransportKind,
2324
} from "./sanitize.js";
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* CL-5170: permission.wait and subagent spans at the ask gate and task fleet.
3+
*/
4+
import { afterEach, describe, expect, test } from "bun:test";
5+
import type { ReactorEmittedEvent } from "@intx/inference";
6+
import { createPermissionGate } from "../permission/gate.js";
7+
import { createTaskTool } from "../subagent/task-tool.js";
8+
import { clear, snapshot, type PerfSpan } from "./index.js";
9+
import { createPerfReactorObserver } from "./reactor-spans.js";
10+
11+
afterEach(() => {
12+
clear();
13+
});
14+
15+
function byName(spans: PerfSpan[], name: string): PerfSpan[] {
16+
return spans.filter((s) => s.name === name);
17+
}
18+
19+
function completed(spans: PerfSpan[]): PerfSpan[] {
20+
return spans.filter((s) => s.endNs !== undefined);
21+
}
22+
23+
function event(type: string, data: unknown = {}): ReactorEmittedEvent {
24+
return { type, seq: 1, data } as ReactorEmittedEvent;
25+
}
26+
27+
const shellCall = (command: string) =>
28+
({ id: "c1", name: "run_shell", arguments: { command } }) as const;
29+
30+
const provider = {
31+
providerName: "test-provider",
32+
baseURL: "http://localhost",
33+
model: "test-model",
34+
};
35+
36+
const skipGate = createPermissionGate({
37+
approvals: [],
38+
interactive: false,
39+
skipPermissions: true,
40+
});
41+
42+
describe("permission.wait spans", () => {
43+
test("records allow decision when operator approves a shell ask", async () => {
44+
const gate = createPermissionGate({
45+
approvals: [],
46+
interactive: true,
47+
skipPermissions: false,
48+
requestApproval: async () => ({ allow: true }),
49+
});
50+
51+
const verdict = await gate.evaluate(shellCall("curl example.com"));
52+
expect(verdict.allowed).toBe(true);
53+
54+
const waits = byName(completed(snapshot()), "permission.wait");
55+
expect(waits).toHaveLength(1);
56+
expect(waits[0]!.tags).toEqual({ tool_id: "run_shell", decision: "allow" });
57+
});
58+
59+
test("records deny decision when operator declines", async () => {
60+
const gate = createPermissionGate({
61+
approvals: [],
62+
interactive: true,
63+
skipPermissions: false,
64+
requestApproval: async () => ({ allow: false, message: "nope" }),
65+
});
66+
67+
const verdict = await gate.evaluate(shellCall("curl example.com"));
68+
expect(verdict.allowed).toBe(false);
69+
70+
const waits = byName(completed(snapshot()), "permission.wait");
71+
expect(waits).toHaveLength(1);
72+
expect(waits[0]!.tags?.decision).toBe("deny");
73+
expect(waits[0]!.tags?.tool_id).toBe("run_shell");
74+
});
75+
76+
test("records path-arg tool ask as permission.wait", async () => {
77+
const gate = createPermissionGate({
78+
approvals: [],
79+
interactive: true,
80+
skipPermissions: false,
81+
requestApproval: async () => ({ allow: true }),
82+
});
83+
84+
const verdict = await gate.evaluate({
85+
id: "c2",
86+
name: "write_file",
87+
arguments: { path: "src/a.ts", content: "x" },
88+
});
89+
expect(verdict.allowed).toBe(true);
90+
91+
const waits = byName(completed(snapshot()), "permission.wait");
92+
expect(waits).toHaveLength(1);
93+
expect(waits[0]!.tags).toEqual({ tool_id: "write_file", decision: "allow" });
94+
});
95+
96+
test("does not open a span when a grant auto-approves", async () => {
97+
let asked = 0;
98+
const gate = createPermissionGate({
99+
approvals: [{ tool: "run_shell", pattern: "npm *" }],
100+
interactive: true,
101+
skipPermissions: false,
102+
requestApproval: async () => {
103+
asked += 1;
104+
return { allow: true };
105+
},
106+
});
107+
108+
const verdict = await gate.evaluate(shellCall("npm test"));
109+
expect(verdict.allowed).toBe(true);
110+
expect(asked).toBe(0);
111+
expect(byName(snapshot(), "permission.wait")).toHaveLength(0);
112+
});
113+
114+
test("nests under the open turn when a reactor turn is active", async () => {
115+
const obs = createPerfReactorObserver();
116+
obs.observe(event("inference.start", { model: "m" }));
117+
obs.observe(
118+
event("inference.done", {
119+
turn: {
120+
role: "assistant",
121+
content: [{ type: "tool_call", id: "t1", name: "run_shell", arguments: {} }],
122+
model: "m",
123+
timestamp: 0,
124+
},
125+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
126+
source: { provider: "p", model: "m" },
127+
}),
128+
);
129+
const turnId = obs.currentTurnId();
130+
expect(turnId).not.toBeNull();
131+
132+
const gate = createPermissionGate({
133+
approvals: [],
134+
interactive: true,
135+
skipPermissions: false,
136+
requestApproval: async () => ({ allow: true }),
137+
});
138+
await gate.evaluate(shellCall("curl x"));
139+
140+
const wait = byName(completed(snapshot()), "permission.wait")[0]!;
141+
expect(wait.parentId).toBe(turnId!);
142+
143+
obs.reset();
144+
});
145+
});
146+
147+
describe("subagent spans", () => {
148+
test("records a completed subagent span around run()", async () => {
149+
let runEntered = false;
150+
const tool = createTaskTool({
151+
permissionGate: skipGate,
152+
cwd: "/repo",
153+
getWorkdirBase: () => "/repo/.corbits",
154+
provider,
155+
run: async () => {
156+
runEntered = true;
157+
// Span must still be open while the child runs.
158+
const open = snapshot().filter((s) => s.name === "subagent" && s.endNs === undefined);
159+
expect(open).toHaveLength(1);
160+
expect(open[0]!.tags?.subagent_id).toBe("call-sa-1");
161+
return "## Summary\n\nok\n";
162+
},
163+
});
164+
if (tool.kind !== "full") throw new Error("expected full tool");
165+
166+
const result = await tool.handler(
167+
{ id: "call-sa-1", name: "task", arguments: { description: "Job", prompt: "Do it" } },
168+
new AbortController().signal,
169+
);
170+
expect(runEntered).toBe(true);
171+
expect(typeof result.content === "string" ? result.content : "").toContain("ok");
172+
173+
const agents = byName(completed(snapshot()), "subagent");
174+
expect(agents).toHaveLength(1);
175+
expect(agents[0]!.tags?.subagent_id).toBe("call-sa-1");
176+
expect(agents[0]!.endNs).toBeDefined();
177+
expect(agents[0]!.endNs! >= agents[0]!.startNs).toBe(true);
178+
});
179+
180+
test("nests under the open turn with turn_id tag for fanout rollup", async () => {
181+
const obs = createPerfReactorObserver();
182+
obs.observe(event("inference.start", { model: "m" }));
183+
obs.observe(
184+
event("inference.done", {
185+
turn: {
186+
role: "assistant",
187+
content: [{ type: "tool_call", id: "task-1", name: "task", arguments: {} }],
188+
model: "m",
189+
timestamp: 0,
190+
},
191+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
192+
source: { provider: "p", model: "m" },
193+
}),
194+
);
195+
const turnId = obs.currentTurnId();
196+
expect(turnId).not.toBeNull();
197+
198+
const tool = createTaskTool({
199+
permissionGate: skipGate,
200+
cwd: "/repo",
201+
getWorkdirBase: () => "/repo/.corbits",
202+
provider,
203+
run: async () => "## Summary\n\nchild done\n",
204+
});
205+
if (tool.kind !== "full") throw new Error("expected full tool");
206+
207+
await tool.handler(
208+
{ id: "call-child", name: "task", arguments: { description: "Child", prompt: "Work" } },
209+
new AbortController().signal,
210+
);
211+
212+
const agent = byName(completed(snapshot()), "subagent")[0]!;
213+
expect(agent.parentId).toBe(turnId!);
214+
expect(agent.tags?.subagent_id).toBe("call-child");
215+
expect(agent.tags?.turn_id).toBe(turnId!);
216+
217+
// Wall time under the child is attributable via parentId (fanout rollup).
218+
const turn = byName(snapshot(), "turn").find((s) => s.id === turnId);
219+
expect(turn).toBeDefined();
220+
expect(agent.startNs >= turn!.startNs).toBe(true);
221+
222+
obs.reset();
223+
});
224+
225+
test("closes the span when run() rejects", async () => {
226+
const tool = createTaskTool({
227+
permissionGate: skipGate,
228+
cwd: "/repo",
229+
getWorkdirBase: () => "/repo/.corbits",
230+
provider,
231+
run: async () => {
232+
throw new Error("boom");
233+
},
234+
});
235+
if (tool.kind !== "full") throw new Error("expected full tool");
236+
237+
const result = await tool.handler(
238+
{ id: "call-fail", name: "task", arguments: { description: "Fail", prompt: "Work" } },
239+
new AbortController().signal,
240+
);
241+
expect(typeof result.content === "string" ? result.content : "").toContain("Error:");
242+
243+
const agents = byName(completed(snapshot()), "subagent");
244+
expect(agents).toHaveLength(1);
245+
expect(agents[0]!.tags?.subagent_id).toBe("call-fail");
246+
expect(agents[0]!.endNs).toBeDefined();
247+
});
248+
});

src/perf/reactor-spans.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@
77
* inference.ttft (start → first content-bearing delta)
88
* inference.stream (first delta → inference.done)
99
* tool (per invocation)
10+
* permission.wait (operator ask; diagnostic nested category — wall time
11+
* overlaps tool; exclusive attribution already excludes
12+
* nested categories, so double-count is intentional)
13+
* subagent (task fleet child wall)
14+
*
15+
* Single-primary assumption: the session run-sink owns one
16+
* `createPerfReactorObserver`. Process-wide `currentTurnId()` is published only
17+
* by that primary so permission.wait / subagent can nest outside the observer.
18+
* Do not create concurrent observers that also call ensureTurn — they would
19+
* overwrite the slot. Tests call `clear()` (and observer `reset()`) between cases.
1020
*/
1121

1222
import type { ReactorEmittedEvent } from "@intx/inference";
@@ -32,8 +42,26 @@ const FIRST_TOKEN_TYPES: ReadonlySet<string> = new Set([
3242
export type PerfReactorObserver = {
3343
observe(event: ReactorEmittedEvent): void;
3444
reset(): void;
45+
/** Opaque PerfTrace id of the open turn span, or null when no turn is open. */
46+
currentTurnId(): string | null;
3547
};
3648

49+
/**
50+
* Process-wide open-turn id from the most recently active reactor observer.
51+
* Permission-wait and subagent spans nest under this when present.
52+
* Tests that open turns should clear() PerfTrace and reset observers between cases.
53+
*/
54+
let activeTurnId: string | null = null;
55+
56+
/** Current open turn span id (for nesting permission.wait / subagent outside the observer). */
57+
export function currentTurnId(): string | null {
58+
return activeTurnId;
59+
}
60+
61+
function setActiveTurnId(id: string | null): void {
62+
activeTurnId = id;
63+
}
64+
3765
type ObserverState = {
3866
turnId: string | null;
3967
inferenceId: string | null;
@@ -139,10 +167,14 @@ export function createPerfReactorObserver(): PerfReactorObserver {
139167
/**
140168
* Single exit for ending a turn: close orphan tool spans, then the turn.
141169
* Inference tree must already be closed (or will be via abandonTurn).
170+
* Clears the process-wide active turn when this observer owns it.
142171
*/
143172
function closeTurn(): void {
144173
closeOpenTools();
145174
endIfOpen(state.turnId);
175+
if (state.turnId !== null && activeTurnId === state.turnId) {
176+
setActiveTurnId(null);
177+
}
146178
state.turnId = null;
147179
state.pendingTools = 0;
148180
}
@@ -156,6 +188,7 @@ export function createPerfReactorObserver(): PerfReactorObserver {
156188
function ensureTurn(): string {
157189
if (state.turnId === null) {
158190
state.turnId = start("turn");
191+
setActiveTurnId(state.turnId);
159192
}
160193
return state.turnId;
161194
}
@@ -257,5 +290,9 @@ export function createPerfReactorObserver(): PerfReactorObserver {
257290
state = emptyState();
258291
}
259292

260-
return { observe, reset };
293+
return {
294+
observe,
295+
reset,
296+
currentTurnId: () => state.turnId,
297+
};
261298
}

0 commit comments

Comments
 (0)