Skip to content

Commit c2a9db1

Browse files
committed
Tag dispatch outcome records with model identity (CL-6968)
Outcome records in interventions.jsonl were written parent-side with no provider/model/family tag, so per-model intervention counts had no dispatch denominator to divide by. Tag each outcome with the provider/ model that actually served inference (preferring the last inference.done source over the pre-dispatch resolved provider, since a mid-run failover can diverge from it), and teach intervention-forensics.ts to report interventions-per-dispatch per model as an explicit rate alongside the existing raw counts.
1 parent d1594a2 commit c2a9db1

3 files changed

Lines changed: 114 additions & 12 deletions

File tree

scripts/intervention-forensics.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@
1111
// edited — stops that fired on a run which had already edited files.
1212
// early — stops that fired before half the turn budget was spent.
1313
//
14-
// Also aggregates outcome records (CL-6938): what each completed dispatch
15-
// actually produced (a salvage kind, or clean-complete), by kind. This is the
16-
// log's only outcome signal, letting a stop record be read alongside what the
14+
// Also aggregates outcome records: what each completed dispatch actually
15+
// produced (a salvage kind, or clean-complete), by kind. This is the log's
16+
// only outcome signal, letting a stop record be read alongside what the
1717
// dispatch it touched actually produced — it is still not gate-pass or
1818
// retry-success tracking.
1919
//
20+
// Outcome records are now tagged with the dispatched child's provider/model/
21+
// family (CL-6968), so they double as the per-model dispatch denominator:
22+
// interventions per model, divided by dispatches per model, is the one table
23+
// below that is an actual rate. Everything else in this script stays a raw
24+
// count — do not add another table that looks like a rate without a tracked
25+
// denominator behind it (that mistake shipped once already and had to be
26+
// stripped, see CL-6968).
27+
//
2028
// Run: bun run scripts/intervention-forensics.ts
2129
//
2230
// Prints only aggregate counts and the `detail` field's first token, never turn
@@ -88,6 +96,12 @@ findAll(root, INTERVENTION_FILE, files);
8896

8997
const buckets = new Map<string, Bucket>();
9098
const outcomes = new Map<string, number>();
99+
// Dispatch counts per model (the outcome record's denominator, CL-6968) and
100+
// intervention counts per model (stop+nudge only — block/outcome are not
101+
// leaf signals), so a rate can be computed instead of a bare count.
102+
const dispatchesByModel = new Map<string, number>();
103+
const interventionsByModel = new Map<string, number>();
104+
let untaggedOutcomes = 0;
91105
let records = 0;
92106
let malformed = 0;
93107

@@ -115,6 +129,12 @@ for (const file of files) {
115129
if (record.class === "outcome" && record.outcome !== undefined) {
116130
const kind = record.outcome.kind;
117131
outcomes.set(kind, (outcomes.get(kind) ?? 0) + 1);
132+
if (record.model !== undefined) {
133+
dispatchesByModel.set(record.model, (dispatchesByModel.get(record.model) ?? 0) + 1);
134+
} else {
135+
// Written before CL-6968 tagged outcome records with model identity.
136+
untaggedOutcomes++;
137+
}
118138
continue;
119139
}
120140
const key = `${record.class ?? "?"}/${record.id}`;
@@ -128,6 +148,9 @@ for (const file of files) {
128148
bucket.byFamily.set(family, (bucket.byFamily.get(family) ?? 0) + 1);
129149
const model = record.model ?? "unknown";
130150
bucket.byModel.set(model, (bucket.byModel.get(model) ?? 0) + 1);
151+
if (record.class === "stop" || record.class === "nudge") {
152+
interventionsByModel.set(model, (interventionsByModel.get(model) ?? 0) + 1);
153+
}
131154
if (record.measurement !== undefined) {
132155
bucket.values.push(record.measurement.value);
133156
if (record.measurement.threshold !== undefined) {
@@ -178,10 +201,10 @@ for (const [key, bucket] of rows) {
178201

179202
// CL-6775: streamed degenerate-repetition aborts (mid-stream, not a turn-level
180203
// stop) get their own model breakdown — "repetition-<detector>" ids, one row
181-
// per model, so "which model loops most" reads off directly. This is a count,
182-
// not a rate normalized by dispatch volume: the log's outcome records (total
183-
// completed dispatches) are written from the parent side without a model tag,
184-
// so a per-model denominator is not yet tracked — see the PR description.
204+
// per model, so "which model loops most" reads off directly. Still a raw
205+
// count, not a rate — see the "interventions per dispatch by model" table
206+
// below for the rate version, computed from the same per-model dispatch
207+
// denominator (outcome records, CL-6968).
185208
const repetitionRows = rows.filter(([key]) => key.includes("/repetition-"));
186209
if (repetitionRows.length > 0) {
187210
const totalsByModel = new Map<string, number>();
@@ -209,6 +232,32 @@ console.log(
209232
"\nedited = stops on runs that had already edited files; early = stops before half the turn budget (context, not a false-positive rate).",
210233
);
211234

235+
// The one real rate in this script: interventions per dispatch, per model.
236+
// dispatches = outcome records tagged with that model (CL-6968); interventions
237+
// = stop+nudge records for that model. Everything above this is a count.
238+
if (dispatchesByModel.size > 0 || interventionsByModel.size > 0) {
239+
console.log("\ninterventions per dispatch by model (stop+nudge count / dispatch count = rate)");
240+
const models = new Set([...dispatchesByModel.keys(), ...interventionsByModel.keys()]);
241+
const modelRows = [...models]
242+
.map((model) => {
243+
const dispatches = dispatchesByModel.get(model) ?? 0;
244+
const interventions = interventionsByModel.get(model) ?? 0;
245+
const rate = dispatches > 0 ? (interventions / dispatches).toFixed(3) : "-";
246+
return { model, dispatches, interventions, rate };
247+
})
248+
.sort((a, b) => b.interventions - a.interventions);
249+
for (const { model, dispatches, interventions, rate } of modelRows) {
250+
console.log(
251+
`${model.padEnd(33)} interventions=${String(interventions).padStart(4)} dispatches=${String(dispatches).padStart(5)} rate=${rate}`,
252+
);
253+
}
254+
if (untaggedOutcomes > 0) {
255+
console.log(
256+
`(${untaggedOutcomes} outcome record(s) predate model tagging (CL-6968) and are excluded from every dispatch count above.)`,
257+
);
258+
}
259+
}
260+
212261
if (outcomes.size > 0) {
213262
console.log("\ndispatch outcomes");
214263
const outcomeRows = [...outcomes.entries()].sort((a, b) => b[1] - a[1]);

src/subagent/intervention-log.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,13 @@ export type InterventionContext = Pick<
9696
>;
9797

9898
export type InterventionSink = (
99-
event: Omit<InterventionRecord, "ts" | "role" | "provider" | "model" | "family" | "intent">,
99+
event: Omit<InterventionRecord, "ts" | "role" | "provider" | "model" | "family" | "intent"> &
100+
// Outcome records are written parent-side, one per completed dispatch, so
101+
// provider/model/family are not fixed at sink construction like a leaf's
102+
// context — they vary per call with the child that was actually dispatched.
103+
// Omitting these keys (not passing them as undefined) leaves the sink's
104+
// bound context untouched for callers that do have a fixed context.
105+
Partial<Pick<InterventionRecord, "provider" | "model" | "family">>,
100106
) => void;
101107

102108
/** Sink that drops everything — the default, so logging is never required. */

src/subagent/task-tool.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
TURN_BUDGET_STOP_AFTER_DISPATCHES,
4444
} from "./brief-dispatch.js";
4545
import { createInterventionLog, type InterventionSink } from "./intervention-log.js";
46+
import { detectModelFamily } from "./provider-family.js";
4647
import { isSubAgentCancelError } from "./dispose.js";
4748
import { cleanupSubAgentWorktree, createSubAgentWorktree, WorktreeError } from "./worktree.js";
4849
import { generateSessionId } from "../session/index.js";
@@ -257,10 +258,26 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
257258
};
258259
// Every completed dispatch gets an outcome record — the log otherwise
259260
// carries shape and run state but never what the run actually produced.
261+
// Tagged with the dispatched child's provider/model/family (CL-6968) so
262+
// per-model intervention counts finally have a denominator: the same
263+
// provider/model this dispatch actually ran under, taken after profile/
264+
// agent inference resolution — never a name the parent merely intended.
260265
let outcomeLog: InterventionSink | null = null;
261-
const recordOutcome = (kind: string, dispatchCount: number): void => {
266+
const recordOutcome = (
267+
kind: string,
268+
dispatchCount: number,
269+
identity: { provider: string; model: string },
270+
): void => {
262271
outcomeLog ??= createInterventionLog(deps.getWorkdirBase(), { role: "parent" });
263-
outcomeLog({ id: "dispatch-outcome", class: "outcome", outcome: { kind, dispatchCount } });
272+
const family = detectModelFamily({ providerName: identity.provider, model: identity.model });
273+
outcomeLog({
274+
id: "dispatch-outcome",
275+
class: "outcome",
276+
outcome: { kind, dispatchCount },
277+
provider: identity.provider,
278+
model: identity.model,
279+
family,
280+
});
264281
};
265282
return tool({
266283
definition: taskToolDefinition,
@@ -608,6 +625,28 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
608625
}
609626
: deps.onEvent;
610627

628+
// A dispatch can fail over to a different configured provider/model
629+
// mid-run (source priority list, `resolveInferenceWithPolicy` builds the
630+
// primary; the reactor retries the next source on error) — so the
631+
// provider/model this call resolved before dispatch is only what the
632+
// parent *intended*. `inference.done` carries the source that actually
633+
// served each cycle; track the last one seen so the outcome record can
634+
// prefer it over the resolved-but-possibly-superseded `provider` value.
635+
let lastCycleSource: { provider: string; model: string } | undefined;
636+
const onEvent = (event: ReactorEmittedEvent): void => {
637+
if (event.type === "inference.done") {
638+
const source = (event.data as { source?: { provider?: string; model?: string } }).source;
639+
if (
640+
source !== undefined &&
641+
typeof source.provider === "string" &&
642+
typeof source.model === "string"
643+
) {
644+
lastCycleSource = { provider: source.provider, model: source.model };
645+
}
646+
}
647+
recordEvent?.(event);
648+
};
649+
611650
const sandbox: SubAgentSandboxDeps = {
612651
permissionGate: deps.permissionGate,
613652
...(deps.inheritMcpTools !== undefined ? { inheritMcpTools: deps.inheritMcpTools } : {}),
@@ -732,7 +771,7 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
732771
...(doNot.length > 0 ? { doNot } : {}),
733772
...(reportFocus !== undefined && reportFocus.length > 0 ? { reportFocus } : {}),
734773
signal: childCtl.signal,
735-
...(recordEvent !== undefined ? { onEvent: recordEvent } : {}),
774+
onEvent,
736775
...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}),
737776
...(capabilities !== undefined ? { capabilities } : {}),
738777
...(systemPromptRole !== undefined ? { systemPromptRole } : {}),
@@ -749,7 +788,15 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
749788
(session !== undefined && deps.sessions?.get(session.id)?.status === "cancelled");
750789
const salvage = classifyBriefSalvage(result);
751790
briefLedger.recordOutcome(fingerprint, salvage);
752-
recordOutcome(salvage ?? "clean-complete", dispatchCount);
791+
// Prefer the last provider/model that actually served inference
792+
// (captured off inference.done above) over the pre-dispatch
793+
// `provider` this call resolved — a mid-run failover to a backup
794+
// source means the two can diverge, and the outcome record should
795+
// describe what the child ran under, not what the parent intended.
796+
recordOutcome(salvage ?? "clean-complete", dispatchCount, {
797+
provider: lastCycleSource?.provider ?? provider.providerName,
798+
model: lastCycleSource?.model ?? provider.model,
799+
});
753800
const hintOptions = {
754801
dispatchCount,
755802
turnBudgetStopAfterDispatches: TURN_BUDGET_STOP_AFTER_DISPATCHES,

0 commit comments

Comments
 (0)