fix(session): end at the step cap with a toolless answer, and report empty task results
Issue for this PR
No GitHub issue yet.
Type of change
- Bug fix
- New feature
- Refactor / code improvement
- Documentation
What does this PR do?
An agent with a steps cap is meant to end with a text answer. On the Altimate gateway it ended with no answer instead, and a capped subagent handed its parent an empty result.
1. The request at the cap failed (session/prompt.ts).
At step N the loop appended MAX_STEPS as a trailing assistant message and still sent every tool, with no toolChoice. On the gateway that request failed with UnknownError "Could not get a response from the agent" and 0 tokens, so the session ended without an answer. In our headless runs these were the only two gateway errors in about 1,000 requests, and the only two requests sent at the cap. The answer-echo path a few lines above already notes that a conversation must end on a user turn.
Fix: at the cap the instruction is a user turn, sent with no tools and toolChoice: "none", the same toolless shape the answer echo, compaction and title requests use; llm.ts then flattens historical tool calls, which the gateway requires for a toolless request. A json_schema request keeps its tools at the cap, because its answer is the StructuredOutput tool call.
2. The cap counted iterations, not requests (session/prompt.ts).
step also increments on loop iterations that run a compaction or a subtask and continue. A capped agent that compacted twice reached its cap several requests early. The cap now uses a separate modelSteps counter that advances only on the normal model-call path. step is unchanged for everything else that reads it (title, telemetry, plan-mode checks).
3. An errored or silent child returned an empty result (tool/task.ts, session/task-output.ts).
The parent received only the child's last text part, so a child that failed at its cap produced <task_result>\n\n</task_result> with status completed, which reads as "done, nothing found". Now, when the child produced no final text, the result says why ("Task stopped with an error before giving a final answer (UnknownError: ā¦)" or "Task ended without a final text answer") and carries the child's last written text if there is one. The <task_result> tag itself is unchanged, since cli/cmd/run/tool.ts parses it.
Not in this PR:
- The step count still restarts when a child is resumed with
task_id. Making it persist per session would also cap interactive sessions for good after N steps in total, so that belongs with a per-run budget that counts child sessions. run --max-turns: passing the remaining turns into the loop would make the last allowed turn the toolless answer, but it changes the documented--max-turnscontract (N work turns, then an abort and one toolless finalization reply, exit 1) thatbudget-finalization.test.tspins, so it is left for a separate change.
How did you verify your code works?
- New
prompt.test.tscases (the step cap asks for a final text answer ā¦), plain and with a usage-triggered compaction, on an OpenAI-compatible mock: the last request ends with a user message carryingMAXIMUM STEPS REACHEDand declares no tools; no earlier request carries it; with a compaction the agent still gets its full step count. Both fail onmain(roleassistant; one request short with the compaction). - New
test/tool/task-empty-result.test.ts: an errored child with earlier text, a child with no text, and a normal answer. The first two fail onmain. bun test test/session test/tool test/agent: 2,131 pass, 0 fail.test/cli: the same 2--time-budgetfailures injson-output.test.tsasmain.budget-finalization.test.ts: 46/46. Anapply_patchcase timed out once under load and passes alone (27/27).- Typecheck clean;
script/check-tracker-leaks.tsandanalyze.ts --markers --strictpass.
Cost side effects
None added. The toolless final request sends no tool schemas, so it does not reuse the cached tools prefix; it is one request per capped session, and it replaces a request that failed and a parent turn that continued without the child's findings.
Screenshots / recordings
N/A
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR
- I checked for cost side effects (model calls, queries or cloud resources that grow with user data) and described them, or wrote "none"
š¤ Generated with Claude Code
Summary by cubic
Fixes agent sessions at the steps cap so they end with a toolless text answer instead of failing with no answer, and makes capped or errored child tasks report why they stopped instead of returning an empty result.
- At the cap the final instruction is now a user turn with tools off; previously it was a trailing assistant message with all tools offered, which the Altimate gateway rejected.
- The cap counts only model requests. Loop iterations that ran a compaction or a subtask no longer consume a step, so capped agents no longer stop early.
- A child task that ends on an error or without a final text answer now tells the parent why, and carries the child's last written text when there is one.
Sections
- A
json_schemarequest keeps its tools at the cap because its answer is theStructuredOutputtool call. - The step count still restarts when a child is resumed with
task_id; a per-run budget that counts child sessions is left for a separate change, as is adjusting--max-turnsto make the last allowed turn the toolless answer.