fix(session): end at the step cap with a toolless answer, and report empty task results

Draft
RA
6h ago
+265-6

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-turns contract (N work turns, then an abort and one toolless finalization reply, exit 1) that budget-finalization.test.ts pins, so it is left for a separate change.

How did you verify your code works?

  • New prompt.test.ts cases (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 carrying MAXIMUM STEPS REACHED and declares no tools; no earlier request carries it; with a compaction the agent still gets its full step count. Both fail on main (role assistant; 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 on main.
  • bun test test/session test/tool test/agent: 2,131 pass, 0 fail. test/cli: the same 2 --time-budget failures in json-output.test.ts as main. budget-finalization.test.ts: 46/46. An apply_patch case timed out once under load and passes alone (27/27).
  • Typecheck clean; script/check-tracker-leaks.ts and analyze.ts --markers --strict pass.

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_schema request keeps its tools at the cap because its answer is the StructuredOutput tool 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-turns to make the last allowed turn the toolless answer.