diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1cf39ff92..95995a294 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - Added `--downshift` / `--downshift-into ` / `--no-downshift`: start on a strong model, then hand off to a fast/cheap one (default the `smol` role) at the first action, with a hidden plan nudge before the switch and a verify checklist after. The nudge asks the model to finish the plan, initialize its todo list from it, and go — the todo init (or any edit/write) is the switch trigger, so the fast model starts with a live checklist and its reminders. Enable per-user with the `downshift.enabled` setting; force it mid-session with the new `/downshift` slash command, which arms the switch even when the session wasn't started with `--downshift` +- Added `--downshift-boomerang` (setting `downshift.boomerang`): once the downshifted model yields naturally with all todos complete, the session hands back to the original model for a brief validation pass — the fast model's turns are cut from the LLM context (the visible transcript keeps them) and the original model gets the fast model's final report with instructions to verify completion and fix anything missing - Added display setting to toggle between collapsing or keeping compacted history inline, now applied to live session displays - Added a compact session-only model picker (Alt+P) for quick model switching without changing roles - Added `@` search to the Alt+P / `/switch` picker: it lists configured Ctrl+P quick roles in matching segment colors and applies the selected role's model and thinking for the current session. diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d60d3a9d3..c648d588b 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -30,6 +30,7 @@ export interface Args { downshift?: boolean; noDownshift?: boolean; downshiftInto?: string; + downshiftBoomerang?: boolean; planYolo?: boolean; planYoloInto?: string; maxTime?: number; @@ -240,6 +241,8 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map = new Set([ "--advisor", "--downshift", "--no-downshift", + "--downshift-boomerang", "--plan-yolo", "--print", "--print-thoughts", diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index d92ea4c07..4e022ed6a 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -44,6 +44,10 @@ export default class Index extends Command { "downshift-into": Flags.string({ description: 'Target model for downshift (default the "smol" role)', }), + "downshift-boomerang": Flags.boolean({ + description: + "After the downshifted model yields with all todos complete, hand back to the original model for a brief validation pass (implies --downshift)", + }), "plan-yolo": Flags.boolean({ description: "Force read-only plan mode at start, auto-approve the plan on the model's first resolve call, then switch to --plan-yolo-into to implement it", diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c2c010caf..100230854 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -433,6 +433,18 @@ export const SETTINGS_SCHEMA = { "Start on the active model, then switch to a fast/cheap model (default the 'smol' role) at the first action — the todo-list init the plan nudge asks for, or any edit/write — with the plan handed off at the switch. Overridable per session with --downshift / --no-downshift.", }, }, + "downshift.boomerang": { + type: "boolean", + default: false, + ui: { + tab: "model", + group: "Downshift", + label: "Boomerang Validation", + description: + "After the downshifted model yields with all todos complete, hand back to the original model for a brief validation pass. The fast model's turns are cut from the LLM context at the hand-back; the visible history is untouched. Overridable per session with --downshift-boomerang.", + condition: "downshiftEnabled", + }, + }, "advisor.subagents": { type: "boolean", default: false, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f91f1e31c..7f9e5d813 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -905,12 +905,12 @@ export async function buildSessionOptions( if (!options.model) options.model = scopedModels[0].model; } - if (parsed.noDownshift && (parsed.downshift || parsed.downshiftInto !== undefined)) { - throw new Error("--no-downshift cannot be combined with --downshift or --downshift-into"); + if (parsed.noDownshift && (parsed.downshift || parsed.downshiftInto !== undefined || parsed.downshiftBoomerang)) { + throw new Error("--no-downshift cannot be combined with --downshift, --downshift-into, or --downshift-boomerang"); } const downshiftEnabled = parsed.noDownshift ? false - : parsed.downshift === true || parsed.downshiftInto !== undefined + : parsed.downshift === true || parsed.downshiftInto !== undefined || parsed.downshiftBoomerang === true ? true : activeSettings.get("downshift.enabled"); if (downshiftEnabled) { @@ -925,7 +925,11 @@ export async function buildSessionOptions( if (!modelRegistry.hasConfiguredAuth(resolved.model)) { throw new Error(`No API key for ${resolved.model.provider}/${resolved.model.id}`); } - options.downshift = { target: resolved.model, thinkingLevel: resolved.thinkingLevel }; + options.downshift = { + target: resolved.model, + thinkingLevel: resolved.thinkingLevel, + boomerang: parsed.downshiftBoomerang ?? activeSettings.get("downshift.boomerang"), + }; } if (parsed.planYoloInto !== undefined && !parsed.planYolo) { diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index dcad6310d..cb4342d1d 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -92,6 +92,13 @@ const CONDITIONS: Record boolean> = { return false; } }, + downshiftEnabled: () => { + try { + return Settings.instance.get("downshift.enabled") === true; + } catch { + return false; + } + }, hindsightActive: () => { try { return Settings.instance.get("memory.backend") === "hindsight"; diff --git a/packages/coding-agent/src/prompts/system/downshift-boomerang.md b/packages/coding-agent/src/prompts/system/downshift-boomerang.md new file mode 100644 index 000000000..6d1daecf1 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/downshift-boomerang.md @@ -0,0 +1,7 @@ +You finished this task and reported: + + +{{finalReport}} + + +However, the working state is uncertain due to external constraints. Briefly validate that the task is genuinely complete: re-check the key changes and run the decisive verification yourself (the specific tests or commands that prove the requirement). If anything is missing or wrong, fix it now. Then yield with a short confirmation. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 03d010ff4..24880f6a7 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -259,6 +259,7 @@ import goalModeContextPrompt from "../prompts/goals/goal-mode-context.md" with { import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" }; import parentIrcSteerTemplate from "../prompts/steering/parent-irc.md" with { type: "text" }; import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" }; +import downshiftBoomerangTemplate from "../prompts/system/downshift-boomerang.md" with { type: "text" }; import downshiftChecklistPrompt from "../prompts/system/downshift-checklist.md" with { type: "text" }; import downshiftContinuePrompt from "../prompts/system/downshift-continue.md" with { type: "text" }; import downshiftPlanPrompt from "../prompts/system/downshift-plan.md" with { type: "text" }; @@ -430,6 +431,9 @@ const DOWNSHIFT_CONTINUE_MESSAGE_TYPE = "downshift-continue"; * multi-site fixes, unnecessarily broad rewrites, and reported-test-only * verification. */ const DOWNSHIFT_CHECKLIST_MESSAGE_TYPE = "downshift-checklist"; +/** `customType` for the hidden validation hand-back injected when a boomerang + * downshift returns to the original model at the run's terminal settle. */ +const DOWNSHIFT_BOOMERANG_MESSAGE_TYPE = "downshift-boomerang"; /** Tools whose first successful call marks the start of the execution phase * that triggers the switch. `todo` is included because the plan nudge tells * the model to initialize its todo list from the finished plan — the todo @@ -725,6 +729,12 @@ export type { ShakeMode, ShakeResult }; */ export interface Downshift { target: Model; + /** Return to the original model for a brief validation pass once the + * downshifted agent yields naturally with all todos complete. The fast + * model's turns are cut from the LLM context at the hand-back (the + * visual transcript keeps them); the original model resumes from its + * own plan handoff plus the fast model's final report. */ + boomerang?: boolean; thinkingLevel?: ConfiguredThinkingLevel; } @@ -1676,6 +1686,9 @@ export class AgentSession { #downshift: Downshift | undefined; /** True once the plan nudge has been queued; scrubbed from context at the switch. */ #downshiftPlanInjected = false; + /** Set at the downshift switch when `boomerang` is configured: the model + * to hand back to at the run's terminal settle. Cleared once fired. */ + #downshiftBoomerang?: { model: Model; thinkingLevel?: ConfiguredThinkingLevel }; #planYolo: PlanYolo | undefined; #planYoloPreviousTools: string[] | undefined; #planYoloArmed = false; @@ -2226,6 +2239,9 @@ export class AgentSession { return; } + if (downshift.boomerang && this.model) { + this.#downshiftBoomerang = { model: this.model, thinkingLevel: this.#thinkingLevel }; + } await this.setModelTemporary(target, downshift.thinkingLevel, { ephemeral: true }); this.#downshift = undefined; this.emitNotice( @@ -2296,6 +2312,62 @@ export class AgentSession { if (filtered.length !== stateMessages.length) this.agent.replaceMessages(filtered); } + /** + * Boomerang: hand a completed downshift run back to the original model for + * a brief validation pass. Fires at the terminal settle only — a natural + * yield (`stopReason === "stop"`) with no incomplete todos. The fast + * model's era (checklist steer onward) is cut from the LLM context so the + * original model resumes from its own plan handoff plus the fast model's + * final report; the visual transcript and persisted history keep every + * turn. One-shot: disarms before switching, so the validation pass itself + * settles normally. Returns true when a continuation was scheduled. + */ + async #advanceDownshiftBoomerang(finalMessage: AssistantMessage): Promise { + const boomerang = this.#downshiftBoomerang; + if (!boomerang || this.#downshift || this.#abortInProgress) return false; + if (finalMessage.stopReason !== "stop") return false; + const hasIncompleteTodos = this.getTodoPhases().some(phase => + phase.tasks.some(task => task.status === "pending" || task.status === "in_progress"), + ); + if (hasIncompleteTodos) return false; + this.#downshiftBoomerang = undefined; + + const finalReport = finalMessage.content + .filter(block => block.type === "text") + .map(block => block.text) + .join("\n") + .trim(); + + // Cut the fast-model era: drop the checklist steer and everything after + // it from the LLM context. If the boundary is gone (e.g. compacted + // away), skip the cut — the validation pass still works, just with the + // full history in context. + const stateMessages = this.agent.state.messages; + const boundary = stateMessages.findLastIndex( + m => m.role === "custom" && m.customType === DOWNSHIFT_CHECKLIST_MESSAGE_TYPE, + ); + if (boundary >= 0) this.agent.replaceMessages(stateMessages.slice(0, boundary)); + + const handback: AgentMessage = { + role: "custom", + customType: DOWNSHIFT_BOOMERANG_MESSAGE_TYPE, + content: prompt.render(downshiftBoomerangTemplate, { finalReport }), + attribution: "agent", + display: false, + timestamp: Date.now(), + }; + this.agent.appendMessage(handback); + this.sessionManager.appendMessage(handback); + await this.setModelTemporary(boomerang.model, boomerang.thinkingLevel, { ephemeral: true }); + this.emitNotice( + "info", + `Downshift: boomerang — back to ${boomerang.model.provider}/${boomerang.model.id} to validate completion.`, + "downshift", + ); + this.#scheduleAgentContinue({ generation: this.#promptGeneration }); + return true; + } + /** * Lazily arm PlanYolo before the first prompt is built: restricts tools to * the plan-mode read-only set (plus `resolve`/`write`, both normally @@ -4536,6 +4608,10 @@ export class AgentSession { await emitAgentEndNotification(); return; } + if (await this.#advanceDownshiftBoomerang(msg)) { + await emitAgentEndNotification(); + return; + } await this.#emitSessionStopEvent(settledMessages, msg); await emitAgentEndNotification(); } diff --git a/packages/coding-agent/test/agent-session-downshift.test.ts b/packages/coding-agent/test/agent-session-downshift.test.ts index d4742bd5e..da3ca7f1c 100644 --- a/packages/coding-agent/test/agent-session-downshift.test.ts +++ b/packages/coding-agent/test/agent-session-downshift.test.ts @@ -95,13 +95,11 @@ describe("AgentSession downshift", () => { return { content: [{ type: "toolCall", id, name, arguments: {} }], stopReason: "toolUse" }; } - function contextMessagesHaveMarker( - contextMessages: Array<{ role: string; content: unknown }>, - marker: string, - ): boolean { + function contextMessagesHaveMarker(contextMessages: ReadonlyArray<{ role: string }>, marker: string): boolean { return contextMessages.some(message => { if (message.role !== "user" && message.role !== "developer") return false; - const content = message.content; + if (!("content" in message)) return false; + const content: unknown = message.content; if (typeof content === "string") return content.includes(marker); if (!Array.isArray(content)) return false; return content.some(block => { @@ -322,4 +320,73 @@ describe("AgentSession downshift", () => { expect(requested).toEqual([`${primary.provider}/${primary.id}`, `${target.provider}/${target.id}`]); expect(session.model?.id).toBe(target.id); }); + + it("boomerang hands back to the original model at the terminal yield, with the fast-model era cut from context", async () => { + const primary = modelOrThrow("claude-sonnet-4-5"); + const target = modelOrThrow("claude-sonnet-4-6"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const planMarker = "complete plan in your NEXT reply"; + const checklistMarker = "grep for every other call site"; + const boomerangMarker = "working state is uncertain"; + + // Turn 1: explore (nudge injected after). Turn 2: write — switch, boomerang + // armed. Turn 3 (fast model): text-only yield with no incomplete todos — + // boomerang fires: context cut back to the pre-switch state plus the + // hand-back message, model returns to primary. Turn 4 (primary): validates. + const mock = createMockModel({ + responses: [ + toolCall("t1", "record"), + toolCall("t2", "write"), + { content: [{ type: "text", text: "All done — implemented the fix." }], stopReason: "stop" }, + { content: ["validated, complete"] }, + ], + }); + const calls: Array<{ model: string; hasChecklist: boolean; hasBoomerang: boolean; hasFlashReport: boolean }> = []; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model: primary, + systemPrompt: ["Test"], + tools: [recordTool as AgentTool, writeTool as AgentTool], + messages: [], + thinkingLevel: Effort.Medium, + }, + convertToLlm, + streamFn: (model, context, options) => { + calls.push({ + model: `${model.provider}/${model.id}`, + hasChecklist: contextMessagesHaveMarker(context.messages, checklistMarker), + hasBoomerang: contextMessagesHaveMarker(context.messages, boomerangMarker), + hasFlashReport: contextMessagesHaveMarker(context.messages, "All done — implemented the fix."), + }); + return mock.stream(model, context, options); + }, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry, + toolRegistry, + downshift: { target, boomerang: true }, + }); + + await session.prompt("do the task"); + + expect(calls.map(call => call.model)).toEqual([ + `${primary.provider}/${primary.id}`, + `${primary.provider}/${primary.id}`, + `${target.provider}/${target.id}`, + `${primary.provider}/${primary.id}`, + ]); + // Checklist visible only during the fast-model era; cut at the hand-back. + expect(calls.map(call => call.hasChecklist)).toEqual([false, false, true, false]); + // Hand-back message present only on the validation turn, carrying the + // fast model's final report. + expect(calls.map(call => call.hasBoomerang)).toEqual([false, false, false, true]); + expect(calls[3].hasFlashReport).toBe(true); + // Back on the original model at the end, and the plan nudge stays scrubbed. + expect(session.model?.id).toBe(primary.id); + expect(contextMessagesHaveMarker(agent.state.messages, planMarker)).toBe(false); + }); });