diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ecedc18104..3988839b9e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -720,6 +720,11 @@ export namespace SessionPrompt { let structuredOutput: unknown | undefined let step = 0 + // altimate_change start — the agent's `steps` cap counts model requests only. `step` also + // counts iterations that run a compaction or a subtask and `continue`, which ended capped + // agents several requests early. + let modelSteps = 0 + // altimate_change end // altimate_change start — which tool catalogs of this loop run the workspace // engine's turn boundary: the first, and the first after a new user request // joins the loop. `step` counts loop iterations, and an iteration can @@ -1516,7 +1521,10 @@ export namespace SessionPrompt { // normal processing const agent = await Agent.get(lastUser.agent) const maxSteps = agent.steps ?? Infinity - const isLastStep = step >= maxSteps + // altimate_change start — see `modelSteps` + modelSteps++ + const isLastStep = modelSteps >= maxSteps + // altimate_change end // altimate_change start — only a completed compaction may change this prefix. // Recover the marker from persisted history too, including a process restart // between summary completion and the next model call. @@ -2040,6 +2048,10 @@ export namespace SessionPrompt { // altimate_change start - every successful commit makes the next generation an echo, including after compaction const echoCommittedAnswer = format.type === "text" && agent.name === "analyst" && committedAnswer !== undefined + // altimate_change start — at the step cap a text answer is produced with tools off. A + // json_schema request keeps its tools: its answer is the StructuredOutput tool call. + const textFinalStep = isLastStep && !echoCommittedAnswer && format.type !== "json_schema" + // altimate_change end const deferredText: MessageV2.TextPart[] = [] // altimate_change end @@ -2151,20 +2163,27 @@ export namespace SessionPrompt { : isLastStep ? [ { - role: "assistant" as const, + // altimate_change start — the step-cap instruction is a user turn, like the echo + // above. Sent as a trailing assistant message with every tool still offered, the + // gateway rejected it and the run ended with no answer. + role: "user" as const, + // altimate_change end content: MAX_STEPS, }, ] : []), // altimate_change end ], - // altimate_change start - the final echo cannot spend another step on tools - tools: echoCommittedAnswer ? {} : tools, + // altimate_change start - the final echo, and the step-cap answer, cannot spend another step on tools. + // Text finalization uses the same toolless shape as compaction and title generation: no + // tools and toolChoice "none", so llm.ts flattens historical tool calls for the gateway. + tools: echoCommittedAnswer || textFinalStep ? {} : tools, deferText: echoCommittedAnswer ? deferredText : undefined, // altimate_change end model, // altimate_change start - a user output schema still takes precedence over text finalization - toolChoice: echoCommittedAnswer ? "none" : format.type === "json_schema" ? "required" : undefined, + toolChoice: + echoCommittedAnswer || textFinalStep ? "none" : format.type === "json_schema" ? "required" : undefined, // altimate_change end }) diff --git a/packages/opencode/src/session/task-output.ts b/packages/opencode/src/session/task-output.ts index 88fe1923c9..4b711561d4 100644 --- a/packages/opencode/src/session/task-output.ts +++ b/packages/opencode/src/session/task-output.ts @@ -22,3 +22,20 @@ export async function interruptedTaskOutput(sessionID: SessionID, parentID?: Mes ].join("\n") } } + +// altimate_change start — the newest model-written text of a child task's current prompt, for a +// task that ends on an error or without a final text answer (see tool/task.ts emptyTaskStatus) +export async function lastTaskText(sessionID: SessionID, parentID?: MessageID): Promise { + for await (const message of MessageV2.stream(sessionID)) { + if (message.info.role === "user") break + if (message.info.role !== "assistant") continue + if (parentID && message.info.parentID !== parentID) continue + const text = message.parts + .filter((part): part is MessageV2.TextPart => part.type === "text" && !part.ignored && !part.synthetic) + .map((part) => part.text) + .join("\n") + .trim() + if (text) return text + } +} +// altimate_change end diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 5a62dbd733..b2e1b20ae2 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -26,7 +26,7 @@ import { RunBudget } from "@/altimate/run-budget" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" // altimate_change start — upstream_fix: recover committed child findings when cancellation interrupts a task. -import { interruptedTaskOutput } from "../session/task-output" +import { interruptedTaskOutput, lastTaskText } from "../session/task-output" // altimate_change end const log = Log.create({ service: "tool.task" }) @@ -253,12 +253,17 @@ export const TaskTool = Tool.define("task", async (ctx) => { if (ctx.abort.aborted) partialOutput ??= await interruptedTaskOutput(session.id, messageID) const text = result?.parts.findLast((x) => x.type === "text")?.text ?? "" + // altimate_change start — a child that ends on an error or with no text must not reach the + // parent as an empty result, which reads as "done, nothing found". + const status = text.trim() || ctx.abort.aborted ? [] : await emptyTaskStatus(session.id, messageID, result) + // altimate_change end const output = partialOutput ?? [ `task_id: ${session.id} (for resuming to continue this task if needed)`, "", "", ...(ctx.abort.aborted ? ["Task interrupted. Findings may be incomplete."] : []), + ...status, text, "", ].join("\n") @@ -278,3 +283,27 @@ export const TaskTool = Tool.define("task", async (ctx) => { }, } }) + +// altimate_change start — explicit status for a child that produced no final text +async function emptyTaskStatus( + sessionID: SessionID, + messageID: MessageID, + result: MessageV2.WithParts | undefined, +): Promise { + const error = result?.info.role === "assistant" ? result.info.error : undefined + const reason = error + ? `Task stopped with an error before giving a final answer (${errorText(error)}).` + : "Task ended without a final text answer." + const partial = await lastTaskText(sessionID, messageID).catch(() => undefined) + return partial + ? [reason, "Its last written text, which may be incomplete:", partial] + : [reason, "It wrote no text. Resume it with task_id or treat its work as unfinished."] +} + +function errorText(error: NonNullable): string { + const message = "data" in error && error.data && typeof error.data === "object" && "message" in error.data + ? String(error.data.message) + : "" + return message ? `${error.name}: ${message}` : error.name +} +// altimate_change end diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 438376b0a7..62492ad809 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -3468,3 +3468,85 @@ noLLMServer.instance( }), 30_000, ) + +// altimate_change start - the step cap ends in a toolless text answer sent after a user turn, +// and only model requests count toward the cap (a compaction iteration does not). +for (const mode of ["plain", "compacted"] as const) { + noLLMServer.instance( + `the step cap asks for a final text answer as a user turn with tools off (${mode})`, + () => + Effect.gen(function* () { + const { prompt, chat, sessions } = yield* boot() + const requests: Record[] = [] + // plain: tool, tool, final. compacted: tool (overflow), summary, tool, final. + const expected = mode === "compacted" ? 4 : 3 + const language = createOpenAICompatible({ + name: "test", + baseURL: "https://unused.invalid/v1", + fetch: Object.assign( + async (_url: string | URL | Request, init?: RequestInit) => { + if (typeof init?.body !== "string") throw new Error("expected a JSON request body") + const request = JSON.parse(init.body) + requests.push(request) + if (requests.length > expected) throw new Error("the step cap was not enforced") + const limited = JSON.stringify(request.messages).includes("CRITICAL - MAXIMUM STEPS REACHED") + const summary = mode === "compacted" && requests.length === 2 + const response = limited + ? reply().text("Maximum steps reached. Done so far: two probes.").stop().item() + : summary + ? reply().text("Summary: two probes were run.").stop().item() + : reply() + .tool("glob", { pattern: `probe-${requests.length}-*` }) + .usage({ input: mode === "compacted" && requests.length === 1 ? 80_000 : 1, output: 1 }) + .item() + if (response.type !== "sse") throw new Error("expected SSE response") + return new Response( + [...response.head, ...response.tail].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + + "data: [DONE]\n\n", + { headers: { "Content-Type": "text/event-stream" } }, + ) + }, + { preconnect: () => undefined }, + ), + }).chatModel("test-model") + const languageSpy = spyOn(ProviderSvc, "getLanguage").mockResolvedValue(language) + try { + const result = yield* prompt.prompt({ + sessionID: chat.id, + agent: "builder", + model: promptRef, + parts: [{ type: "text", text: "Probe the project, one glob at a time." }], + }) + expect(requests).toHaveLength(expected) + const last = requests.at(-1)! + // Before the fix the cap message was a trailing assistant turn with every tool still offered. + expect(requestMessages(last).at(-1)!.role).toBe("user") + expect(requestText(requestMessages(last).at(-1)!.content)).toContain("CRITICAL - MAXIMUM STEPS REACHED") + expect(last.tools ?? []).toHaveLength(0) + for (const earlier of requests.slice(0, -1)) { + expect(JSON.stringify(earlier.messages)).not.toContain("CRITICAL - MAXIMUM STEPS REACHED") + } + if (mode === "compacted") { + const messages = yield* sessions.messages({ sessionID: chat.id }) + expect(messages.some((message) => message.info.role === "assistant" && message.info.summary)).toBe(true) + } + expect(result.info).not.toHaveProperty("error") + expect(result.parts).toContainEqual( + expect.objectContaining({ type: "text", text: "Maximum steps reached. Done so far: two probes." }), + ) + } finally { + languageSpy.mockRestore() + } + }), + { + config: { + ...cfg, + snapshot: false, + learn: { enabled: false }, + agent: { builder: { steps: 3, permission: { glob: "allow" } } }, + }, + }, + 30_000, + ) +} +// altimate_change end diff --git a/packages/opencode/test/tool/task-empty-result.test.ts b/packages/opencode/test/tool/task-empty-result.test.ts new file mode 100644 index 0000000000..c6e8e11a20 --- /dev/null +++ b/packages/opencode/test/tool/task-empty-result.test.ts @@ -0,0 +1,112 @@ +// A child task that ends on an error (for example the request at its step cap) or with +// no text used to reach the parent as an empty , which reads as "done, nothing found". +import { expect, spyOn, test } from "bun:test" +import { Effect } from "effect" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, SessionID } from "../../src/session/schema" +import { TaskTool, type TaskPromptOps } from "../../src/tool/task" +import { initTool } from "../altimate/tool-fixture" + +const cases = [ + { + name: "an errored child with earlier text returns the error and that text", + error: { name: "UnknownError", data: { message: "Could not get a response from the agent." } }, + text: "", + earlier: "Found 3 candidates so far.", + contains: [ + "Task stopped with an error before giving a final answer (UnknownError: Could not get a response from the agent.).", + "Its last written text, which may be incomplete:", + "Found 3 candidates so far.", + ], + }, + { + name: "a child with no text at all says so", + error: undefined, + text: "", + earlier: undefined, + contains: [ + "Task ended without a final text answer.", + "It wrote no text. Resume it with task_id or treat its work as unfinished.", + ], + }, + { + name: "a child with a final answer is passed through unchanged", + error: undefined, + text: "Child answer", + earlier: undefined, + contains: ["\nChild answer\n"], + }, +] as const + +for (const item of cases) { + test(item.name, async () => { + const parentID = SessionID.make("ses_empty_parent") + const childID = SessionID.make("ses_empty_child") + const messageID = MessageID.make("msg_empty_parent") + const agent = { name: "general", mode: "subagent", permission: [], options: {} } as unknown as Agent.Info + let childPromptID: MessageID | undefined + const mocks = [ + spyOn(Agent, "list").mockResolvedValue([agent]), + spyOn(Agent, "get").mockResolvedValue(agent), + spyOn(Config, "get").mockResolvedValue({}), + spyOn(Session, "get").mockImplementation( + Object.assign( + async (id: SessionID) => + ({ + id, + permission: [], + ...(id === childID ? { parentID, metadata: { taskAgent: "general" } } : {}), + }) as never, + Session.get, + ), + ), + spyOn(MessageV2, "get").mockResolvedValue({ + info: { role: "assistant", parentID: messageID, modelID: "test", providerID: "test" }, + parts: [], + } as never), + // Newest first, like the real stream: the failed final message, then an earlier one with text. + spyOn(MessageV2, "stream").mockImplementation(async function* () { + yield { info: { role: "assistant", parentID: childPromptID }, parts: [] } as never + if (item.earlier) + yield { + info: { role: "assistant", parentID: childPromptID }, + parts: [{ type: "text", text: item.earlier }], + } as never + yield { info: { role: "user" }, parts: [] } as never + } as never), + ] + const promptOps: TaskPromptOps = { + cancel: () => Effect.void, + resolvePromptParts: (text) => Effect.succeed([{ type: "text", text }]), + prompt: (input) => + Effect.sync(() => { + childPromptID = input.messageID + return { + info: { role: "assistant", ...(item.error ? { error: item.error } : {}) }, + parts: item.text ? [{ type: "text", text: item.text }] : [], + } as unknown as MessageV2.WithParts + }), + } + try { + const tool = await initTool(TaskTool) + const result = await tool.execute( + { description: "Inspect records", prompt: "Summarize the records", subagent_type: "general", task_id: childID }, + { + sessionID: parentID, + messageID, + agent: "build", + abort: new AbortController().signal, + messages: [], + extra: { promptOps, bypassAgentCheck: true }, + }, + ) + for (const text of item.contains) expect(result.output).toContain(text) + if (item.text) expect(result.output).not.toContain("Task ended without") + } finally { + for (const mock of mocks) mock.mockRestore() + } + }) +}