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
71 changes: 65 additions & 6 deletions src/subagent/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
classifyBriefSalvage,
EMPTY_THRASH_STATE,
nextThrashState,
salvagePathsFromThrash,
evaluateToolLessNarrationSpiral,
MAX_TOOLLESS_NARRATION_CYCLES,
partialTextFromEvent,
preferCompletedSubAgentReply,
resolveSubAgentCatchOutcome,
Expand Down Expand Up @@ -184,6 +187,30 @@ describe("sub-agent stop helpers", () => {
).toBe("incomplete-report-stop");
});

test("evaluateToolLessNarrationSpiral nudges once then stops at the cycle cap", () => {
expect(evaluateToolLessNarrationSpiral(1)).toBe("nudge");
expect(evaluateToolLessNarrationSpiral(MAX_TOOLLESS_NARRATION_CYCLES)).toBe("stop");
expect(evaluateToolLessNarrationSpiral(MAX_TOOLLESS_NARRATION_CYCLES + 1)).toBe("stop");
});

test("evaluateSubAgentStop spiral uses toolLessNarrationCycles over the deprecated flag", () => {
expect(
evaluateSubAgentStop({
hasToolCalls: false,
lastAssistantText: SUMMARY_ONLY_NARRATION,
toolLessNarrationCycles: 1,
incompleteReportNudgeFired: true,
}),
).toBe("incomplete-report");
expect(
evaluateSubAgentStop({
hasToolCalls: false,
lastAssistantText: SUMMARY_ONLY_NARRATION,
toolLessNarrationCycles: 2,
}),
).toBe("incomplete-report-stop");
});

test("evaluateSubAgentStop returns complete for tool-less after tools with all four headings", () => {
expect(
evaluateSubAgentStop({
Expand Down Expand Up @@ -390,28 +417,60 @@ describe("sub-agent stop helpers", () => {
expect(deadlineWithHint).toContain("wall-clock deadline");
expect(deadlineWithHint).toContain("deadline reached");
// Only fires for a deadline report, not for other forced-stop reasons.
expect(
appendSubAgentParentHints(forcedStopReport("cancelled", "x"), "cancelled"),
).not.toContain("wall-clock deadline");
const cancelledWithHint = appendSubAgentParentHints(
forcedStopReport("cancelled", "x"),
"cancelled",
);
expect(cancelledWithHint).not.toContain("wall-clock deadline");
expect(cancelledWithHint).toContain("was cancelled before finishing");
expect(cancelledWithHint).toContain("Findings and Paths");

// Paths section carries thrash salvage; empty prose with paths still informs Findings.
const withPaths = forcedStopReport("cancelled", "", {
paths: ["src/a.ts", "src/b.ts"],
});
const withPathsParsed = parseSubAgentReport(withPaths);
expect(withPathsParsed.paths).toContain("src/a.ts");
expect(withPathsParsed.paths).toContain("src/b.ts");
expect(withPathsParsed.findings).toContain("Files touched before stop");
expect(withPathsParsed.findings).toContain("src/a.ts");
});

test("forcedStopReport renders a Stopped line for display; classification uses the typed reason", () => {
expect(forcedStopReport("cancelled", "partial", "Session closed")).toMatch(
expect(forcedStopReport("cancelled", "partial", { detail: "Session closed" })).toMatch(
/^Stopped: cancelled — Session closed\n/,
);
expect(forcedStopReport("cancelled", "partial")).toMatch(/^Stopped: cancelled\n/);
expect(forcedStopReport("deadline", "x", "30s elapsed")).toMatch(
expect(forcedStopReport("deadline", "x", { detail: "30s elapsed" })).toMatch(
/^Stopped: deadline — 30s elapsed\n/,
);
// Nested Stopped: under Findings is display-only; classify via typed reason.
const nested = forcedStopReport(
"deadline",
forcedStopReport("cancelled", "inner", "inner reason"),
forcedStopReport("cancelled", "inner", { detail: "inner reason" }),
);
expect(nested).toMatch(/^Stopped: deadline\n/);
expect(nested).toContain("Stopped: cancelled — inner reason");
});

test("salvagePathsFromThrash prefers edited paths then collapses chunked reads", () => {
const state = nextThrashState(EMPTY_THRASH_STATE, [
{
type: "tool_call",
name: "read_file",
arguments: { path: "src/a.ts", offset: 0, limit: 10 },
},
{
type: "tool_call",
name: "edit_file",
arguments: { path: "src/b.ts", old_string: "a", new_string: "b" },
},
{ type: "tool_call", name: "read_file", arguments: { path: "src/a.ts" } },
]);
expect(salvagePathsFromThrash(state)).toEqual(["src/b.ts", "src/a.ts"]);
expect(salvagePathsFromThrash(state, 1)).toEqual(["src/b.ts"]);
});

test("createSubAgentRunController aborts on an explicit deadline and reports deadlineHit", async () => {
const ctl = createSubAgentRunController(undefined, 20);
expect(ctl.signal.aborted).toBe(false);
Expand Down
11 changes: 10 additions & 1 deletion src/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export {
type FleetObservation,
type FleetWatch,
} from "./fleet-report.js";
export { EMPTY_THRASH_STATE, nextThrashState, type ThrashState } from "./thrash.js";
export {
EMPTY_THRASH_STATE,
nextThrashState,
salvagePathsFromThrash,
type ThrashState,
} from "./thrash.js";
export {
appendActivitySummary,
buildDispatchBrief,
Expand All @@ -36,17 +41,21 @@ export {
} from "./report.js";
export {
SUBAGENT_DEADLINE_MARGIN_MS,
MAX_TOOLLESS_NARRATION_CYCLES,
appendSubAgentParentHints,
evaluateSubAgentStop,
evaluateToolLessNarrationSpiral,
forcedStopReport,
partialTextFromEvent,
preferCompletedSubAgentReply,
resolveSubAgentCatchOutcome,
resolveSubAgentDeadlineMs,
type ForcedStopReason,
type ForcedStopReportOptions,
type SubAgentCatchOutcome,
type SubAgentParentHintOptions,
type SubAgentStopReason,
type ToolLessNarrationSpiral,
} from "./stop-policy.js";

export {
Expand Down
2 changes: 2 additions & 0 deletions src/subagent/nudge-director.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ describe("SubAgentDirector incomplete-report wiring", () => {
if (reply === undefined || reply.type !== "reply") throw new Error("expected reply action");
expect(reply.content).toContain("narrated instead of writing a report envelope");
expect(reply.content).toContain("Still narrating, no envelope.");
expect(reply.content).toContain("## Paths");
expect(reply.content).toContain("read-1.ts");
});

test("tool-less turn with the four headings completes normally", async () => {
Expand Down
32 changes: 21 additions & 11 deletions src/subagent/nudge-director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import type {
} from "@intx/types/runtime";
import { createCompactionGovernor, type CompactionGovernor } from "../agent/compaction.js";
import { onTurnBoundary } from "../agent/reactor-events.js";
import { EMPTY_THRASH_STATE, nextThrashState, type ThrashState } from "./thrash.js";
import {
EMPTY_THRASH_STATE,
nextThrashState,
salvagePathsFromThrash,
type ThrashState,
} from "./thrash.js";
import { NOOP_INTERVENTION_SINK, type InterventionSink } from "./intervention-log.js";
import {
evaluateSubAgentStop,
Expand Down Expand Up @@ -86,8 +91,9 @@ export class SubAgentDirector extends DefaultDirector {
// already completed.
private lastConsumedNudgeText: string | null = null;
// Soft incomplete-report wrap-up is one-shot per run; a second tool-less
// narration without the envelope salvages as incomplete-report.
private incompleteReportNudgeFired = false;
// narration without the envelope salvages as incomplete-report
// (MAX_TOOLLESS_NARRATION_CYCLES = 2).
private toolLessNarrationCycles = 0;

// Stall management: a leaf that goes quiet (e.g. parked on a long-running
// background command with nothing else to do) produces no inbound events
Expand Down Expand Up @@ -213,7 +219,7 @@ export class SubAgentDirector extends DefaultDirector {
thrashState: this.thrashState,
requireEvidence: this.requireEvidence,
lastAssistantText: this.lastAssistantText,
incompleteReportNudgeFired: this.incompleteReportNudgeFired,
toolLessNarrationCycles: this.toolLessNarrationCycles + 1,
});

if (stop === "complete") {
Expand All @@ -229,7 +235,7 @@ export class SubAgentDirector extends DefaultDirector {
if (stop === "incomplete-report") {
// Tool-less turn after tools, no report envelope. Must not fall through
// to super.decide — DefaultDirector completes any tool-less turn.
this.incompleteReportNudgeFired = true;
this.toolLessNarrationCycles += 1;
this.interventions({
id: "incomplete-report",
class: "nudge",
Expand All @@ -242,6 +248,7 @@ export class SubAgentDirector extends DefaultDirector {
];
}
if (stop === "incomplete-report-stop") {
this.toolLessNarrationCycles += 1;
this.interventions({
id: "incomplete-report-stop",
class: "stop",
Expand All @@ -251,7 +258,11 @@ export class SubAgentDirector extends DefaultDirector {
this.onForcedStop("incomplete-report");
const terminal: ReactorAction[] = [
capabilities.checkpoint("subagent-incomplete-report"),
capabilities.reply(forcedStopReport("incomplete-report", this.lastAssistantText)),
capabilities.reply(
forcedStopReport("incomplete-report", this.lastAssistantText, {
paths: salvagePathsFromThrash(this.thrashState),
}),
),
];
this.compaction.noteIdleTurn(event, terminal);
const compacted = this.compaction.interceptActions(event, terminal, capabilities);
Expand Down Expand Up @@ -330,11 +341,10 @@ export class SubAgentDirector extends DefaultDirector {
const terminal: ReactorAction[] = [
capabilities.checkpoint("subagent-stalled"),
capabilities.reply(
forcedStopReport(
"stalled",
this.lastAssistantText,
`no activity for ${Math.round(elapsed / 1000)}s after stall nudge`,
),
forcedStopReport("stalled", this.lastAssistantText, {
detail: `no activity for ${Math.round(elapsed / 1000)}s after stall nudge`,
paths: salvagePathsFromThrash(this.thrashState),
}),
),
];
return terminal;
Expand Down
67 changes: 59 additions & 8 deletions src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
resolveSubAgentDeadlineMs,
type ForcedStopReason,
} from "./stop-policy.js";
import { EMPTY_THRASH_STATE, nextThrashState, salvagePathsFromThrash } from "./thrash.js";
import { SubAgentDirector } from "./nudge-director.js";
import { assertTierMayMountFleetVerb } from "./authority.js";
import { createReadAgentTraceTool } from "./trace-tool.js";
Expand Down Expand Up @@ -268,6 +269,22 @@ function abortReasonText(signal: AbortSignal): string | undefined {
return undefined;
}

/**
* Findings payload for cancel/deadline salvage. Prefer multi-turn accumulated
* prose; fall back to the last turn-boundary text, then the in-flight cycle tail.
*/
function salvageFindingsText(
accumulatedProse: string,
lastPartialText: string,
abortedCycleText: string,
): string {
const prior = accumulatedProse.trim();
if (prior.length > 0) return prior;
const last = lastPartialText.trim();
if (last.length > 0) return last;
return abortedCycleText.slice(-2000);
}

/**
* Arm requireEvidence only for the critic director. Greybeard is also
* intent=review and may spawn-only then envelope; that is not a fake
Expand Down Expand Up @@ -772,6 +789,12 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
// transcript (which would interleave sub-agent text with the parent turn).
const toolNamesUsed: string[] = [];
let lastPartialText = "";
// Accumulate assistant prose across turns (capped) so cancel/deadline
// salvage Findings keep substantive mid-run text, not only the final cycle.
const TURN_PROSE_CAP = 12_000;
let accumulatedProse = "";
// Thrash paths from tool.start so mid-tool cancel still lists files touched.
let thrashState = EMPTY_THRASH_STATE;
// Watch the streamed text of the in-flight cycle so a salvage on
// cancel/deadline has the cycle's tail as its payload, even though no
// turn boundary has completed yet to carry it.
Expand All @@ -782,9 +805,27 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
toolNamesUsed.push(name);
params.onProgress?.({ description: params.description, toolName: name });
}
if (event.type === "tool.start") {
const call = (event as { data?: { call?: { name?: unknown; arguments?: unknown } } }).data
?.call;
if (typeof call?.name === "string" && call.name.length > 0) {
thrashState = nextThrashState(thrashState, [
{ type: "tool_call", name: call.name, arguments: call.arguments },
]);
}
}
cycleRecorder.handleEvent(event);
const partial = partialTextFromEvent(event);
if (partial !== null) lastPartialText = partial;
if (partial !== null) {
lastPartialText = partial;
const trimmed = partial.trim();
if (trimmed.length > 0) {
const joined =
accumulatedProse.length === 0 ? trimmed : `${accumulatedProse}\n\n${trimmed}`;
accumulatedProse =
joined.length <= TURN_PROSE_CAP ? joined : joined.slice(-TURN_PROSE_CAP);
}
}
params.onEvent?.(event);
};
streamPromise = consumeStream(agent.stream(), streamSink);
Expand Down Expand Up @@ -927,11 +968,13 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
if (interruptController.signal.aborted && !runController.signal.aborted) {
interruptedKeepAlive = true;
const abortedCycleText = await cycleRecorder.dispose("cancelled", { drain: streamPromise });
const tail =
lastPartialText.trim().length > 0 ? lastPartialText : abortedCycleText.slice(-2000);
const tail = salvageFindingsText(accumulatedProse, lastPartialText, abortedCycleText);
return {
report: appendActivitySummary(
forcedStopReport("cancelled", tail, "interrupted by interrupt_agent"),
forcedStopReport("cancelled", tail, {
detail: "interrupted by interrupt_agent",
paths: salvagePathsFromThrash(thrashState),
}),
toolNamesUsed,
),
stopReason: "cancelled",
Expand All @@ -953,15 +996,17 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
// Deadline always salvages (even with zero output). Cancel after any
// tools or assistant prose salvages so the parent keeps partial work;
// pre-progress cancel still surfaces as a bare AbortError.
const hadProgress = toolNamesUsed.length > 0 || lastPartialText.trim().length > 0;
const hadProgress =
toolNamesUsed.length > 0 ||
lastPartialText.trim().length > 0 ||
accumulatedProse.trim().length > 0;
const outcome = resolveSubAgentCatchOutcome({
deadlineHit: runController.deadlineHit(),
hadProgress,
});
if (outcome !== "rethrow") {
const reason = outcome === "salvage-deadline" ? "deadline" : "cancelled";
const tail =
lastPartialText.trim().length > 0 ? lastPartialText : abortedCycleText.slice(-2000);
const tail = salvageFindingsText(accumulatedProse, lastPartialText, abortedCycleText);
const detail =
reason === "deadline" && resolvedDeadlineMs !== undefined
? `${resolvedDeadlineMs}ms elapsed`
Expand All @@ -973,7 +1018,13 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<RunSubAgen
...(detail !== undefined ? { detail } : {}),
});
return {
report: appendActivitySummary(forcedStopReport(reason, tail, detail), toolNamesUsed),
report: appendActivitySummary(
forcedStopReport(reason, tail, {
...(detail !== undefined ? { detail } : {}),
paths: salvagePathsFromThrash(thrashState),
}),
toolNamesUsed,
),
stopReason: reason,
};
}
Expand Down
Loading
Loading