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
125 changes: 125 additions & 0 deletions src/coding-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type WatchdogConfig,
} from "./watchdog";
import { shouldCompact, pickCutoff } from "./compaction-policy";
import { shouldRunFinalSummary, stripHarnessNotices } from "./final-summary-policy";
import { detectSameToolRepetition } from "./loop-detection";
import { pruneOversizedToolResults } from "./own-loop-prune";
import { nextRetry } from "./overflow-retry";
Expand Down Expand Up @@ -855,6 +856,11 @@ export class CodingAgent extends Think<Env, DodoConfig> {
let compactionTriggered = false;
let consecutiveNoTextSteps = 0; // Track iterations where the model produces tool calls but no text
let exitReason: "natural" | "step-limit" | "budget-limit" | "doom-loop" | "no-text-loop" | "text-loop" | "abort" = "natural";
// Running total of plain text emitted across iterations. Used after the
// loop to decide whether a forced final-summary turn is needed (only
// when the loop ended on a stuck signal and the model never wrote
// a real conclusion).
let turnText = "";

// ─── Budget thresholds (% of tokenBudget) ───
const WARN_THRESHOLD = 0.70;
Expand Down Expand Up @@ -1359,6 +1365,7 @@ export class CodingAgent extends Think<Env, DodoConfig> {
const c = chunk as { type?: string; delta?: string; errorText?: string; error?: string };
if (c.type === "text-delta" && c.delta) {
iterationText += c.delta;
turnText += c.delta;
} else if (c.type === "error") {
hasErrorChunk = true;
errorText = c.errorText ?? c.error ?? "unknown";
Expand Down Expand Up @@ -1540,6 +1547,124 @@ export class CodingAgent extends Think<Env, DodoConfig> {
exitReason = "step-limit";
}

// ─── Final-summary turn (after a stuck-loop exit) ───
//
// When the loop exits on a stuck signal (doom-loop, no-text-loop,
// text-loop, or budget hard-stop without auto-continuation),
// the model often left only the harness's own
// "[Stopped: ...]" delta in the user-visible response — no real
// conclusion. The auto-continuation block below SKIPS those exits
// by design (they mean the model is stuck and shouldn't be
// restarted). But it leaves users with a stop notice and no
// answer.
//
// Run one more single-turn streamText with NO tools and a
// strict 'write your conclusion now' system message. The model
// has no choice but to emit text. Bounded by a short timeout
// so this can't itself loop.
//
// Guards:
// - skipped on abort (the user asked to stop)
// - skipped on `natural` exit (the model already wrapped up)
// - skipped when the turn already produced substantive text
// (>=200 chars of non-stop-notice content)
// - skipped on cost-runaway exits where there's no point
// spending more tokens
const FINAL_SUMMARY_MIN_EXISTING_TEXT = 200;
const FINAL_SUMMARY_TIMEOUT_MS = 30_000;
const FINAL_SUMMARY_MAX_OUTPUT_TOKENS = 800;

const turnTextWithoutHarnessNotices = stripHarnessNotices(turnText);
const runFinalSummary = shouldRunFinalSummary({
exitReason,
signalAborted: !!signal?.aborted,
turnText,
minExistingTextChars: FINAL_SUMMARY_MIN_EXISTING_TEXT,
});

if (runFinalSummary) {
log("info", "own-loop: final-summary turn starting", {
sessionId,
exitReason,
existingTextChars: turnTextWithoutHarnessNotices.length,
step,
});

// Pure-text system injection appended to the existing messages.
// No tools handed to the model — it cannot make another tool
// call, only write text. This is the whole point.
const summaryInjection: ModelMessage = {
role: "system" as const,
content: [
"[FINAL TURN — NO TOOLS AVAILABLE]",
"The session has been stopped by the harness because " +
(exitReason === "doom-loop"
? "you called the same tool too many times in a row"
: exitReason === "no-text-loop"
? "you made many tool calls without writing any text"
: "your responses started repeating") +
".",
"Write your final answer to the user now. Summarise:",
" 1. What you were trying to do.",
" 2. What you actually found out (the useful information from your tool calls).",
" 3. What you would have done next if you'd had more turns.",
"Do NOT apologise, do NOT explain that you stopped — the user already knows. Just give the conclusion.",
].join("\n"),
};

const summaryMessages = [...messages, summaryInjection];

// Bound this turn with a fresh AbortController chained to the
// outer signal, so a timeout here doesn't leak the outer
// controller. We can't directly time-bound streamText, but
// we can race its iterator against a timer.
const summaryController = new AbortController();
const onOuterAbort = () => summaryController.abort();
signal?.addEventListener("abort", onOuterAbort, { once: true });
const timeoutHandle = setTimeout(() => {
summaryController.abort();
log("warn", "own-loop: final-summary turn timed out", {
sessionId,
timeoutMs: FINAL_SUMMARY_TIMEOUT_MS,
});
}, FINAL_SUMMARY_TIMEOUT_MS);

try {
const summaryResult = streamText({
model,
system,
messages: summaryMessages,
tools: {}, // No tools — the model must write text.
maxOutputTokens: FINAL_SUMMARY_MAX_OUTPUT_TOKENS,
abortSignal: summaryController.signal,
});
// Separator so the conclusion is visibly distinct from
// any harness stop notice that came before it.
yield {
type: "text-delta",
id: crypto.randomUUID(),
delta: "\n\n---\n\n",
};
for await (const chunk of summaryResult.toUIMessageStream()) {
yield chunk;
}
log("info", "own-loop: final-summary turn complete", {
sessionId,
exitReason,
});
} catch (err) {
log("warn", "own-loop: final-summary turn failed", {
sessionId,
error: err instanceof Error ? err.message : String(err),
});
// Non-fatal — the stop notice already in `turnText` is the
// user-visible result; we tried for more and failed.
} finally {
clearTimeout(timeoutHandle);
signal?.removeEventListener("abort", onOuterAbort);
}
}

// ─── Multi-phase auto-continuation ───
// When the loop ends due to resource limits (step or budget), truncate
// context in-memory and start a new phase. Repeats up to MAX_PHASES
Expand Down
70 changes: 70 additions & 0 deletions src/final-summary-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Pure decision logic for the own-loop's "final-summary turn" feature.
*
* After the own-loop exits, the harness sometimes wants to run one more
* no-tools turn to elicit a real conclusion from the model — but only
* when:
* - the loop exited because the model was stuck (not because it
* finished naturally, hit a cost backstop, or the user aborted),
* - AND the model didn't already write a substantive text answer.
*
* Extracted from `coding-agent.ts:onChatMessage()` so the boundary
* conditions are testable without booting a Worker.
*/

export type OwnLoopExitReason =
| "natural"
| "step-limit"
| "budget-limit"
| "doom-loop"
| "no-text-loop"
| "text-loop"
| "abort";

/** Exit reasons that mean "the model got stuck — try one more nudge". */
export const STUCK_EXIT_REASONS: ReadonlySet<OwnLoopExitReason> = new Set([
"doom-loop",
"no-text-loop",
"text-loop",
]);

/**
* Strip the harness's own bracketed notices ("[Stopped: ...]",
* "[Compacting context ...]", "[Loop detected ...]") from a string.
* Used when judging whether the model itself produced a real text
* answer — without this scrub, a 50-char stop notice would count as
* "model wrote text" and we'd skip the final-summary turn.
*/
export function stripHarnessNotices(text: string): string {
return text
.replace(/\[Stopped:[^\]]*\]/g, "")
.replace(/\[Compacting context[^\]]*\]/g, "")
.replace(/\[Loop detected[^\]]*\]/g, "")
.trim();
}

export interface FinalSummaryDecisionInputs {
/** How the own-loop exited. */
exitReason: OwnLoopExitReason;
/** Whether the outer abort signal was tripped. */
signalAborted: boolean;
/** All assistant text emitted across the turn so far. */
turnText: string;
/**
* If the model already wrote at least this many chars of
* non-harness-notice text, the summary turn is skipped. Defaults
* to 200.
*/
minExistingTextChars?: number;
}

/** Should the own-loop run a no-tools final-summary turn? */
export function shouldRunFinalSummary(
inputs: FinalSummaryDecisionInputs,
): boolean {
if (inputs.signalAborted) return false;
if (!STUCK_EXIT_REASONS.has(inputs.exitReason)) return false;
const min = inputs.minExistingTextChars ?? 200;
const stripped = stripHarnessNotices(inputs.turnText);
return stripped.length < min;
}
Loading
Loading