Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
`search_agents` with no supported opt-in.
- Removed the default 30-turn leaf sub-agent ceiling; an unset `maxTurns` now runs unbounded (explicit budgets still apply).
- Deleted two unenforced orchestrator prompt rules: a "4 workers at once" fan-out cap and a same-agent lane-disjointness rule.
- Sub-agent forced-stop outcomes (turn budget, no-progress, deadline,
cancelled, etc.) are now classified from the structured stop reason the run
reports directly, not by re-parsing the parent-facing report's prose.
Removes the `isXxxSubAgentReport` classifier family and per-reason parent
hint functions in favor of a single structured switch.

## [0.2.108] - 2026-08-24

Expand Down
16 changes: 12 additions & 4 deletions src/agent/director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,16 @@ function taskTurn(id: string): ReactorInboundEvent {
// "successful leaf tool.done" progress signal.
function taskDoneEvent(
callId: string,
options: { isError?: boolean; content?: string } = {},
options: { isError?: boolean; content?: string; stopReason?: string } = {},
): ReactorInboundEvent {
return {
type: "tool.done",
result: { callId, isError: options.isError ?? false, content: options.content ?? "ok" },
result: {
callId,
isError: options.isError ?? false,
content: options.content ?? "ok",
...(options.stopReason !== undefined ? { detail: { stopReason: options.stopReason } } : {}),
},
} as unknown as ReactorInboundEvent;
}

Expand Down Expand Up @@ -866,7 +871,7 @@ describe("ChatDirector tool-only loop protection", () => {
await director.decide(
{
type: "tool.done",
result: { callId: "task-1", content: salvage },
result: { callId: "task-1", content: salvage, detail: { stopReason: "no-ship" } },
} as unknown as ReactorInboundEvent,
mockState,
capabilities,
Expand Down Expand Up @@ -1071,7 +1076,10 @@ describe("ChatDirector tool-only loop protection", () => {
await director.decide(taskTurn(id), mockState, capabilities);
const result = actionsArray(
await director.decide(
taskDoneEvent(id, { content: forcedStopReport("no-progress", "x") }),
taskDoneEvent(id, {
content: forcedStopReport("no-progress", "x"),
stopReason: "no-progress",
}),
mockState,
capabilities,
),
Expand Down
13 changes: 8 additions & 5 deletions src/agent/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { PRESENT_VIEW_PRIMITIVES_GUIDANCE } from "./tool-schema-normalize.js";
import { isOperatorOriginated } from "./message-provenance.js";
import { classifyBriefSalvage, isHardBlockSalvage } from "../subagent/brief-dispatch.js";
import type { ForcedStopReason } from "../subagent/stop-policy.js";
import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js";

// Fired when turnsSinceUserMessage reaches TURNS_SINCE_USER_MESSAGE_BACKSTOP.
Expand Down Expand Up @@ -933,8 +934,11 @@ class ChatDirectorImpl extends DefaultDirector {

if (event.type === "tool.done" && this.pendingTaskCallIds.has(event.result.callId)) {
this.pendingTaskCallIds.delete(event.result.callId);
const body = typeof event.result.content === "string" ? event.result.content : "";
const salvage = classifyBriefSalvage(body);
const detail = event.result.detail as { stopReason?: ForcedStopReason } | undefined;
const salvage = classifyBriefSalvage({
...(detail?.stopReason !== undefined ? { stopReason: detail.stopReason } : {}),
wasCancelled: false,
});
if (salvage !== null && isHardBlockSalvage(salvage) && !this.salvageNudgeFired) {
this.salvageNudgeFired = true;
this.pendingSalvageNudge = PRIMARY_SALVAGE_NUDGE;
Expand All @@ -958,9 +962,8 @@ class ChatDirectorImpl extends DefaultDirector {
// the backstop forever — once the cap is exhausted, leaf successes
// stop resetting the interval and the nudge/pause escalation
// eventually forces an operator checkpoint. Credit also requires the
// tool result content to actually be a string: non-string content is
// coerced to "" above only for salvage classification (an empty body
// classifies as success), which must not also buy backstop credit.
// tool result content to actually be a string, independent of the
// structured salvage classification above.
if (
!event.result.isError &&
salvage === null &&
Expand Down
6 changes: 3 additions & 3 deletions src/perf/permission-subagent-spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ describe("subagent spans", () => {
const open = snapshot().filter((s) => s.name === "subagent" && s.endNs === undefined);
expect(open).toHaveLength(1);
expect(open[0]!.tags?.subagent_id).toBe("call-sa-1");
return "## Summary\n\nok\n";
return { report: "## Summary\n\nok\n" };
},
});
if (tool.kind !== "full") throw new Error("expected full tool");
Expand Down Expand Up @@ -281,7 +281,7 @@ describe("subagent spans", () => {
cwd: "/repo",
getWorkdirBase: () => "/repo/.corbits",
provider,
run: async () => "## Summary\n\nchild done\n",
run: async () => ({ report: "## Summary\n\nchild done\n" }),
});
if (tool.kind !== "full") throw new Error("expected full tool");

Expand Down Expand Up @@ -346,7 +346,7 @@ describe("subagent spans", () => {
useWorktree: true,
run: async () => {
runEntered = true;
return "## Summary\n\nshould not run\n";
return { report: "## Summary\n\nshould not run\n" };
},
});
if (tool.kind !== "full") throw new Error("expected full tool");
Expand Down
40 changes: 22 additions & 18 deletions src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "./agent-fleet.js";
import { createSubAgentSessionStore } from "./session-store.js";
import { createPermissionGate } from "../permission/gate.js";
import type { RunSubAgentParams } from "./types.js";
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";

const testPermissionGate = createPermissionGate({
approvals: [],
Expand Down Expand Up @@ -37,7 +37,7 @@ function deferred<T>(): {
}

function makeDeps(
run: (params: RunSubAgentParams) => Promise<string>,
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>,
opts: { cwd?: string } = {},
): AgentFleetDeps {
return {
Expand Down Expand Up @@ -75,7 +75,7 @@ async function callTool(

describe("spawn_agent", () => {
test("returns immediately with a running agent_id without waiting for the worker", async () => {
const gate = deferred<string>();
const gate = deferred<RunSubAgentResult>();
const deps = makeDeps(async () => gate.promise);
const spawn = createSpawnAgentTool(deps);

Expand All @@ -94,13 +94,17 @@ describe("spawn_agent", () => {
// Worker is still pending; store confirms it has not finished.
expect(deps.sessions.get(result.agent_id as string)?.status).toBe("running");

gate.resolve("done");
gate.resolve({ report: "done" });
});
});

describe("spawn_agent + wait_agents", () => {
test("wait_agents on one target returns once it completes while siblings keep running", async () => {
const gates = [deferred<string>(), deferred<string>(), deferred<string>()];
const gates = [
deferred<RunSubAgentResult>(),
deferred<RunSubAgentResult>(),
deferred<RunSubAgentResult>(),
];
let callIndex = 0;
const deps = makeDeps(async () => {
const i = callIndex++;
Expand All @@ -116,7 +120,7 @@ describe("spawn_agent + wait_agents", () => {
);
const ids = spawned.map((s) => s.agent_id as string);

gates[0]!.resolve("first report");
gates[0]!.resolve({ report: "first report" });

const waited = await callTool(wait, { targets: [ids[0]], timeout_ms: 5000 });
expect(waited.timed_out).toBe(false);
Expand All @@ -129,12 +133,12 @@ describe("spawn_agent + wait_agents", () => {
expect(deps.sessions.get(ids[1]!)?.status).toBe("running");
expect(deps.sessions.get(ids[2]!)?.status).toBe("running");

gates[1]!.resolve("second");
gates[2]!.resolve("third");
gates[1]!.resolve({ report: "second" });
gates[2]!.resolve({ report: "third" });
});

test("wait_agents times out on a still-running agent without cancelling it, and can be called again", async () => {
const gate = deferred<string>();
const gate = deferred<RunSubAgentResult>();
const deps = makeDeps(async () => gate.promise);
const spawn = createSpawnAgentTool(deps);
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });
Expand All @@ -155,7 +159,7 @@ describe("spawn_agent + wait_agents", () => {
expect(deps.sessions.get(id)?.status).toBe("running");

// A second wait still works cleanly (either another timeout, or completion).
gate.resolve("finished");
gate.resolve({ report: "finished" });
const second = await callTool(wait, { targets: [id], timeout_ms: 5000 });
expect(second.timed_out).toBe(false);
const secondResults = second.results as {
Expand All @@ -168,7 +172,7 @@ describe("spawn_agent + wait_agents", () => {
});

test("wait_agents with no targets waits on all currently running spawned agents", async () => {
const gates = [deferred<string>(), deferred<string>()];
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
let callIndex = 0;
const deps = makeDeps(async () => gates[callIndex++]!.promise);
const spawn = createSpawnAgentTool(deps);
Expand All @@ -177,14 +181,14 @@ describe("spawn_agent + wait_agents", () => {
await callTool(spawn, { description: "a", prompt: "do it", intent: "explore" });
await callTool(spawn, { description: "b", prompt: "do it", intent: "explore" });

gates[0]!.resolve("a done");
gates[0]!.resolve({ report: "a done" });
const result = await callTool(wait, { timeout_ms: 5000 });
expect(result.timed_out).toBe(false);
const results = result.results as { status: string }[];
expect(results).toHaveLength(2);
expect(results.some((r) => r.status === "done")).toBe(true);

gates[1]!.resolve("b done");
gates[1]!.resolve({ report: "b done" });
});

test("reports survive well past the session store's display cap (20) until wait_agents collects them", async () => {
Expand All @@ -193,7 +197,7 @@ describe("spawn_agent + wait_agents", () => {
// them is collected, proving fleetRecords — not the store — is what
// wait_agents actually reads from.
const COUNT = 25;
const deps = makeDeps(async () => "irrelevant");
const deps = makeDeps(async () => ({ report: "irrelevant" }));
const spawn = createSpawnAgentTool(deps);
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });

Expand Down Expand Up @@ -226,7 +230,7 @@ describe("spawn_agent + wait_agents", () => {

describe("spawn_agent write-lane isolation", () => {
test("refuses a second concurrent implement-intent spawn against the same cwd", async () => {
const gate = deferred<string>();
const gate = deferred<RunSubAgentResult>();
const deps = makeDeps(async () => gate.promise, { cwd: "/repo" });
const spawn = createSpawnAgentTool(deps);

Expand All @@ -246,11 +250,11 @@ describe("spawn_agent write-lane isolation", () => {
expect(second.content).toContain("Error:");
expect(second.content).toContain(first.agent_id as string);

gate.resolve("done");
gate.resolve({ report: "done" });
});

test("does not refuse a second concurrent explore-intent spawn against the same cwd", async () => {
const deps = makeDeps(async () => "explored", { cwd: "/repo" });
const deps = makeDeps(async () => ({ report: "explored" }), { cwd: "/repo" });
const spawn = createSpawnAgentTool(deps);

const first = await callTool(spawn, {
Expand All @@ -269,7 +273,7 @@ describe("spawn_agent write-lane isolation", () => {
});

test("releases the write lane once the implement worker finishes, allowing another", async () => {
const deps = makeDeps(async () => "built", { cwd: "/repo" });
const deps = makeDeps(async () => ({ report: "built" }), { cwd: "/repo" });
const spawn = createSpawnAgentTool(deps);
const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords });

Expand Down
13 changes: 9 additions & 4 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ import { resolveEffortForRole } from "../provider/reasoning-effort.js";
import { isCodexProviderName } from "../config/codex-providers.js";
import { buildDispatchBrief, type TaskIntent } from "./report.js";
import type { SubAgentSessionStore } from "./session-store.js";
import type { RunSubAgentParams, SubAgentProvider, SubAgentSandboxDeps } from "./types.js";
import type {
RunSubAgentParams,
RunSubAgentResult,
SubAgentProvider,
SubAgentSandboxDeps,
} from "./types.js";
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
import { classifyAgentName } from "../telemetry/classify.js";

Expand Down Expand Up @@ -212,7 +217,7 @@ export type AgentFleetDeps = SubAgentSandboxDeps & {
cwd: string;
getWorkdirBase: () => string;
provider: SubAgentProvider | (() => SubAgentProvider);
run: (params: RunSubAgentParams) => Promise<string>;
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>;
sessions: SubAgentSessionStore;
fleetRecords: FleetRecordsHandle;
settings?: Settings | (() => Settings | undefined);
Expand Down Expand Up @@ -462,8 +467,8 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
.then((result) => {
releaseWriteLane();
if (childCtl.signal.aborted) return;
deps.fleetRecords.resolve(session.id, result);
deps.sessions.complete(session.id, result);
deps.fleetRecords.resolve(session.id, result.report);
deps.sessions.complete(session.id, result.report);
})
.catch((err) => {
releaseWriteLane();
Expand Down
57 changes: 15 additions & 42 deletions src/subagent/brief-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,15 @@
*/

import type { TaskIntent } from "./report.js";
import {
isDeadlineSubAgentReport,
isForcedStopSubAgentReport,
isNeverActedSubAgentReport,
isNeverEditedSubAgentReport,
isNoProgressSubAgentReport,
isNoShipSubAgentReport,
isRepetitionSubAgentReport,
isTurnBudgetSubAgentReport,
} from "./stop-policy.js";
import type { ForcedStopReason } from "./stop-policy.js";

/** Salvage classes that must not be re-dispatched with an identical brief. */
export type HardBlockSalvage =
"no-ship" | "no-progress" | "repetition" | "never-acted" | "never-edited";

export type BriefSalvageKind =
HardBlockSalvage | "turn-budget" | "deadline" | "stalled" | "cancelled" | "incomplete-report";
// Every forced-stop reason a leaf can report maps 1:1 onto a salvage kind
// the parent ledger cares about.
export type BriefSalvageKind = ForcedStopReason;

export interface TaskBriefFingerprintInput {
prompt: string;
Expand Down Expand Up @@ -64,38 +56,19 @@ export function isHardBlockSalvage(kind: BriefSalvageKind): kind is HardBlockSal
return HARD_BLOCK_SALVAGES.has(kind);
}

/** True when the worker returned a stall salvage report. */
export function isStalledSubAgentReport(report: string): boolean {
return isForcedStopSubAgentReport(report, "stalled");
}

/** True when the worker returned a cancel salvage report. */
export function isCancelledSubAgentReport(report: string): boolean {
return isForcedStopSubAgentReport(report, "cancelled");
}

/** True when the worker returned an incomplete-report salvage (narration, no envelope). */
export function isIncompleteReportSubAgentReport(report: string): boolean {
return isForcedStopSubAgentReport(report, "incomplete-report");
}

/**
* Classify a sub-agent tool result body as a salvage kind the parent ledger cares
* about. Returns null for normal completes (or unrecognized envelopes).
* Classify a completed dispatch as a salvage kind the parent ledger cares
* about, from the structured stop reason the run reported directly — never
* by matching the report body's prose. `wasCancelled` (observed independently,
* e.g. via the parent's own abort signal) takes precedence since a parent
* cancel can race a run that never got to report its own reason.
*/
export function classifyBriefSalvage(report: string): BriefSalvageKind | null {
// Order: more specific salvage phrases first.
if (isNoShipSubAgentReport(report)) return "no-ship";
if (isRepetitionSubAgentReport(report)) return "repetition";
if (isNeverEditedSubAgentReport(report)) return "never-edited";
if (isNeverActedSubAgentReport(report)) return "never-acted";
if (isNoProgressSubAgentReport(report)) return "no-progress";
if (isTurnBudgetSubAgentReport(report)) return "turn-budget";
if (isDeadlineSubAgentReport(report)) return "deadline";
if (isStalledSubAgentReport(report)) return "stalled";
if (isCancelledSubAgentReport(report)) return "cancelled";
if (isIncompleteReportSubAgentReport(report)) return "incomplete-report";
return null;
export function classifyBriefSalvage(input: {
stopReason?: ForcedStopReason;
wasCancelled: boolean;
}): BriefSalvageKind | null {
if (input.wasCancelled) return "cancelled";
return input.stopReason ?? null;
}

/**
Expand Down
Loading
Loading