feat(ai-chat): raise agent step cap 8->20, force a final text answer

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>
This commit is contained in:
claude code agent 227
2026-06-20 05:38:13 +03:00
parent c8af637654
commit b197cbedef
2 changed files with 73 additions and 2 deletions

View File

@@ -1,4 +1,9 @@
import { compactToolOutput } from './ai-chat.service';
import {
compactToolOutput,
prepareAgentStep,
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
/**
* Unit tests for compactToolOutput: the pure helper that shrinks LARGE tool
@@ -66,3 +71,30 @@ describe('compactToolOutput', () => {
expect(compactedBytes).toBeLessThan(originalBytes / 10);
});
});
/**
* Unit tests for prepareAgentStep: the pure helper that decides per-step
* overrides for the agent loop. Early steps return undefined (default
* behavior); the final allowed step (stepNumber === MAX_AGENT_STEPS - 1) forces
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
* appended onto — not replacing — the original system prompt.
*/
describe('prepareAgentStep', () => {
it('returns undefined for the first step', () => {
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
});
it('returns undefined for a non-final step (just before the last)', () => {
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
});
it('forces a text-only synthesis on the final allowed step', () => {
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
expect(result).toBeDefined();
expect(result?.toolChoice).toBe('none');
// The original persona is preserved (prefix), not replaced.
expect(result?.system.startsWith('SYS')).toBe(true);
// The synthesis instruction is appended.
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
});
});

View File

@@ -17,6 +17,39 @@ import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { McpClientsService } from './external-mcp/mcp-clients.service';
import { buildSystemPrompt } from './ai-chat.prompt';
// Max agent steps per turn. One step = one model generation; a step that calls
// tools is followed by another step carrying the tool results. Raised from 8 so
// multi-search research questions are not cut off mid-investigation.
const MAX_AGENT_STEPS = 20;
// System-prompt addendum injected ONLY on the final step (see prepareAgentStep).
// It forbids further tool calls and tells the model to synthesize the best
// answer it can from what it already gathered, so a tool-heavy turn never ends
// empty.
const FINAL_STEP_INSTRUCTION =
'You have reached the maximum number of tool-use steps for this turn. ' +
'Do NOT call any more tools. Using only the information already gathered, ' +
"write the most complete, useful final answer you can now, in the user's " +
'language. If the information is incomplete, say so explicitly: summarize ' +
'what you found, what is still missing, and give your best partial conclusion.';
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
// steps; on the final allowed step forces a text-only synthesis answer.
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
// whole system prompt for the step.
export function prepareAgentStep(
stepNumber: number,
system: string,
): { toolChoice: 'none'; system: string } | undefined {
if (stepNumber >= MAX_AGENT_STEPS - 1) {
return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` };
}
return undefined;
}
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
/**
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
@@ -244,7 +277,13 @@ export class AiChatService {
// cap would truncate complex tool calls mid-argument. Let the model use its
// natural per-step budget. (Cost/credit limits are an account concern, not
// something to enforce by silently breaking the agent.)
stopWhen: stepCountIs(8),
stopWhen: stepCountIs(MAX_AGENT_STEPS),
// Forced finalization: reserve the LAST allowed step for a text-only
// answer. Without this, a turn that spends all its steps on tool calls
// ends with no assistant text (an empty turn). prepareAgentStep forbids
// further tool calls and appends a synthesis instruction on that step,
// concatenated onto the original `system` so the persona is preserved.
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
abortSignal: signal,
onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => {
await persistAssistant({