feat(ai-chat): step cap 8→20 + forced final text answer #9

Merged
Ghost merged 3 commits from feat/ai-chat-step-limit into develop 2026-06-20 17:47:37 +03:00

Implements docs/backlog/ai-chat-step-limit-and-forced-final-answer.md.

What

Raises the AI agent's per-turn step cap from 8 to 20 and guarantees a non-empty answer: the final allowed step is forced to be text-only, so a tool-heavy research turn can no longer end with an empty assistant message.

How

Single file, server-only: apps/server/src/core/ai-chat/ai-chat.service.ts.

  • MAX_AGENT_STEPS = 20 replaces the magic stepCountIs(8) in stopWhen.
  • New pure helper prepareAgentStep(stepNumber, system) returns undefined for normal steps, and on the final step (stepNumber >= MAX_AGENT_STEPS - 1) returns { toolChoice: 'none', system: \${system}\n\n${FINAL_STEP_INSTRUCTION}` }`.
  • Wired via prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system), placed next to stopWhen. The instruction is concatenated with the in-scope built system prompt, never replacing the persona/safety layers (the AI SDK v6 system override is a full replacement, hence concat).

Reasoning / decisions

  • Why a pure helper: the streamText loop is expensive to unit-test; extracting the per-step decision into a pure function (mirroring the existing compactToolOutput pattern) makes the boundary behavior deterministically testable without the model.
  • Why concat, not replace: AI SDK v6 prepareStep.system fully replaces the step's system message; replacing would drop persona + the non-removable safety framework. Confirmed against ai@6.0.207 d.ts (per the plan).
  • Why reserve exactly one step: guaranteeing a synthesized answer is worth more than one extra tool call; reserving 2 would cut useful work. toolChoice:'none' makes the model emit text → loop ends as stop, not a truncated empty turn.
  • v7 note: prepareStep.system is renamed instructions in AI SDK 7 — flagged in a code comment for the future bump.

Review findings

Self-reviewed (small, localized change); no separate review-subagent pass given the size and the pure-function test coverage. No issues found; the in-scope prompt variable is confirmed to be system.

Verification

  • pnpm --filter server build — clean.
  • pnpm --filter server test -- ai-chat.service9/9 pass (6 pre-existing compactToolOutput + 3 new prepareAgentStep: step 0 → undefined, step 18 → undefined, step 19 → toolChoice:'none' + system starts with original prompt and contains the synthesis instruction).
  • Browser (headless Chromium, live z.ai provider): opened the AI chat, sent a normal prompt → assistant reply streamed (SSE 200), follow-up turn also worked → confirms prepareStep returns undefined for normal steps and streaming is intact. No app errors.

🤖 Generated with Claude Code

Implements `docs/backlog/ai-chat-step-limit-and-forced-final-answer.md`. ## What Raises the AI agent's per-turn step cap from **8 to 20** and guarantees a non-empty answer: the final allowed step is forced to be text-only, so a tool-heavy research turn can no longer end with an empty assistant message. ## How Single file, server-only: `apps/server/src/core/ai-chat/ai-chat.service.ts`. - `MAX_AGENT_STEPS = 20` replaces the magic `stepCountIs(8)` in `stopWhen`. - New pure helper `prepareAgentStep(stepNumber, system)` returns `undefined` for normal steps, and on the final step (`stepNumber >= MAX_AGENT_STEPS - 1`) returns `{ toolChoice: 'none', system: \`${system}\n\n${FINAL_STEP_INSTRUCTION}\` }`. - Wired via `prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system)`, placed next to `stopWhen`. The instruction is **concatenated** with the in-scope built `system` prompt, never replacing the persona/safety layers (the AI SDK v6 `system` override is a full replacement, hence concat). ## Reasoning / decisions - **Why a pure helper:** the `streamText` loop is expensive to unit-test; extracting the per-step decision into a pure function (mirroring the existing `compactToolOutput` pattern) makes the boundary behavior deterministically testable without the model. - **Why concat, not replace:** AI SDK v6 `prepareStep.system` fully replaces the step's system message; replacing would drop persona + the non-removable safety framework. Confirmed against `ai@6.0.207` `d.ts` (per the plan). - **Why reserve exactly one step:** guaranteeing a synthesized answer is worth more than one extra tool call; reserving 2 would cut useful work. `toolChoice:'none'` makes the model emit text → loop ends as `stop`, not a truncated empty turn. - v7 note: `prepareStep.system` is renamed `instructions` in AI SDK 7 — flagged in a code comment for the future bump. ## Review findings Self-reviewed (small, localized change); no separate review-subagent pass given the size and the pure-function test coverage. No issues found; the in-scope prompt variable is confirmed to be `system`. ## Verification - `pnpm --filter server build` — clean. - `pnpm --filter server test -- ai-chat.service` — **9/9 pass** (6 pre-existing `compactToolOutput` + 3 new `prepareAgentStep`: step 0 → undefined, step 18 → undefined, step 19 → `toolChoice:'none'` + system starts with original prompt and contains the synthesis instruction). - Browser (headless Chromium, live z.ai provider): opened the AI chat, sent a normal prompt → assistant reply streamed (SSE 200), follow-up turn also worked → confirms `prepareStep` returns `undefined` for normal steps and streaming is intact. No app errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 2 commits 2026-06-20 05:38:46 +03:00
A narrow research question could burn all 8 steps on tool calls and end the
turn with no assistant text (empty turn). Two changes:
- MAX_AGENT_STEPS = 20 (was a magic stepCountIs(8)) so multi-search turns
  aren't cut off mid-investigation.
- prepareStep reserves the LAST allowed step for a text-only synthesis:
  toolChoice 'none' + a FINAL_STEP_INSTRUCTION appended to (not replacing)
  the system prompt, so a tool-heavy turn always ends with a real answer.
Logic extracted into the pure, exported prepareAgentStep(stepNumber, system)
for unit testing; earlier steps return undefined (default behavior).

Implements docs/backlog/ai-chat-step-limit-and-forced-final-answer.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vvzvlad added 1 commit 2026-06-20 17:47:28 +03:00
Port two refinements from the GLM variant onto the Claude base:
- prepareAgentStep: add a comment note that AI SDK v7 renames the per-step
  `system` field to `instructions` (v6 ^6.0.134 still uses `system`), so it
  gets updated correctly on the next SDK bump.
- ai-chat.service.spec: add an explicit off-by-one boundary test for
  prepareAgentStep, expressed via MAX_AGENT_STEPS instead of a hardcoded 18/19
  so it tracks the constant if the cap changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost merged commit 965cbb32e5 into develop 2026-06-20 17:47:37 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#9