fix(agent): surface empty assistant answers instead of silent stops (#1830)
The support chat showed 'Tänker' and then went quiet with no answer and no error. Root cause: the 2026-08-21 RIP-3 cutover moved general.help to the single-call POST /api/agent/ask, whose 1500-token default cap made stop_reason max_tokens routine on tool-loop turns. The empty answer then passed unlogged through the service, the route answered 200 with an empty string, and the console appended an invisible empty bubble. Fixes, single-call path: - ask-service: default maxTokens 1500 -> 5400 (the streaming chat's reply ceiling); an empty final answer now logs model + usage and throws the typed EmptyModelAnswerError instead of passing through. - /api/agent/ask: maxDuration 300, logger, empty answer maps to 502 with 'Assistenten gav inget svar. Försök igen.'; an empty assistant turn is never persisted (the question stays, so retry works). - AskConsole: a 200 with an empty answer shows the error box instead of appending an invisible bubble. - anthropic-family: serialized tool results are bounded at 40000 chars (mirrors run-turn) so one big read cannot eat the output budget; the step-exhausted fallback keeps tools declared with tool_choice none, because replaying tool_use/tool_result without tools is an API 400. Fixes, streaming path (same silent class): - /api/agent/invoke: maxDuration 300 so deep thinking turns are not killed mid-stream at the platform default cap. - run-turn: stop_reason max_tokens with no visible text emits an error event, not a bare turn_complete. - AgentChat: an NDJSON stream that ends without turn_complete or error (and was not aborted) shows 'Anslutningen bröts innan svaret blev klart. Försök igen.' The lib/ai request-shape tests were updated deliberately for the fallback change; general.help stays on the single-call runtime (founder decision, not reverted). Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
78525bd391
commit
6aecbdc9b7
@@ -262,7 +262,7 @@ describe('generateText read-only tool loop', () => {
|
||||
expect(result.text).toBe('Kunde inte hämta momsrapporten just nu.')
|
||||
})
|
||||
|
||||
it('forces a final tools-off answer when the step budget is exhausted', async () => {
|
||||
it('forces a final no-more-tools answer when the step budget is exhausted', async () => {
|
||||
const execute = vi.fn().mockResolvedValue({ ok: true })
|
||||
const svc = createAnthropicFamilyService(readAiConfig())
|
||||
// Every turn keeps asking for the tool → never terminates on its own.
|
||||
@@ -294,9 +294,60 @@ describe('generateText read-only tool loop', () => {
|
||||
tools: [{ name: 't', description: 'd', jsonSchema: {}, execute }],
|
||||
maxSteps: 2,
|
||||
})
|
||||
// 2 loop turns + 1 forced final = 3 calls; the final call omits tools.
|
||||
// 2 loop turns + 1 forced final = 3 calls. Deliberate request-shape change
|
||||
// (2026-08-24): the final call KEEPS the tools declared, because the
|
||||
// transcript it replays holds tool_use/tool_result blocks the API must be
|
||||
// able to resolve (omitting tools made every step-exhausted answer a 400),
|
||||
// and forbids further calls via tool_choice none.
|
||||
expect(mockCreate).toHaveBeenCalledTimes(3)
|
||||
expect(mockCreate.mock.calls[2][0].tools).toBeUndefined()
|
||||
expect(mockCreate.mock.calls[2][0].tools).toHaveLength(1)
|
||||
expect(mockCreate.mock.calls[2][0].tools[0].name).toBe('t')
|
||||
expect(mockCreate.mock.calls[2][0].tool_choice).toEqual({ type: 'none' })
|
||||
expect(result.text).toBe('Sammanfattning utan fler verktygsanrop.')
|
||||
})
|
||||
|
||||
it('bounds an oversized tool result before feeding it back to the model', async () => {
|
||||
const big = 'x'.repeat(120_000)
|
||||
const execute = vi.fn().mockResolvedValue({ text: big })
|
||||
const svc = createAnthropicFamilyService(readAiConfig())
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
stop_reason: 'tool_use',
|
||||
content: [{ type: 'tool_use', id: 'tu', name: 'gnubok_get_document_content', input: {} }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
})
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
stop_reason: 'end_turn',
|
||||
content: [{ type: 'text', text: 'Sammanfattat.' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
})
|
||||
await svc.generateText({
|
||||
tier: 'assistant',
|
||||
prompt: 'x',
|
||||
maxTokens: 100,
|
||||
tools: [{ name: 'gnubok_get_document_content', description: 'd', jsonSchema: {}, execute }],
|
||||
})
|
||||
const toolResult = mockCreate.mock.calls[1][0].messages[2].content[0]
|
||||
// 40k cap plus the short truncation notice; nowhere near the raw 120k.
|
||||
expect(toolResult.content.length).toBeLessThan(40_400)
|
||||
expect(toolResult.content).toContain('[avkortat: resultatet var')
|
||||
})
|
||||
|
||||
it('returns empty text (for the caller to guard on) when the loop ends on max_tokens with no text block', async () => {
|
||||
const svc = createAnthropicFamilyService(readAiConfig())
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
stop_reason: 'max_tokens',
|
||||
content: [],
|
||||
usage: { input_tokens: 5, output_tokens: 100 },
|
||||
})
|
||||
const result = await svc.generateText({
|
||||
tier: 'assistant',
|
||||
prompt: 'x',
|
||||
maxTokens: 100,
|
||||
tools: [{ name: 't', description: 'd', jsonSchema: {}, execute: vi.fn() }],
|
||||
})
|
||||
// The service stays a transport: it does not invent text. Callers
|
||||
// (ask-service) treat '' as a typed failure instead of a silent answer.
|
||||
expect(result.text).toBe('')
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,20 @@ import type {
|
||||
|
||||
const DEFAULT_MAX_STEPS = 4
|
||||
|
||||
// Bound a serialized tool result before it enters the model context. Mirrors
|
||||
// boundToolResultText in lib/agent/chat/run-turn.ts: same 40k-char ceiling
|
||||
// (~10k tokens, well under the ~25k-token practical ceiling for a single tool
|
||||
// return). Unbounded, one large read (full OCR text, a big ledger report) is
|
||||
// re-sent on every later loop turn and can eat the whole max_tokens budget
|
||||
// before the model produces any visible text.
|
||||
const MAX_TOOL_RESULT_CHARS = 40_000
|
||||
|
||||
function boundToolResult(raw: string): string {
|
||||
if (raw.length <= MAX_TOOL_RESULT_CHARS) return raw
|
||||
const head = raw.slice(0, MAX_TOOL_RESULT_CHARS)
|
||||
return `${head}\n\n[avkortat: resultatet var ${raw.length} tecken, visar de första ${MAX_TOOL_RESULT_CHARS}. Be om en smalare sökning (limit, datumintervall, specifikt id eller fält) för att se mer.]`
|
||||
}
|
||||
|
||||
const EMPTY_USAGE: AiUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -173,16 +187,27 @@ export function createAnthropicFamilyService(cfg: ResolvedAiConfig): AiService {
|
||||
isError = true
|
||||
content = JSON.stringify({ error: err instanceof Error ? err.message : 'Tool failed' })
|
||||
}
|
||||
results.push({ type: 'tool_result', tool_use_id: block.id, content, is_error: isError })
|
||||
results.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: block.id,
|
||||
content: boundToolResult(content),
|
||||
is_error: isError,
|
||||
})
|
||||
}
|
||||
messages.push({ role: 'user', content: results })
|
||||
}
|
||||
|
||||
// Spent the step budget without a final answer: force one with tools off.
|
||||
// Spent the step budget without a final answer: force one more turn that
|
||||
// cannot call tools. The tools param MUST stay in the request: the
|
||||
// transcript above holds tool_use/tool_result blocks, and the Messages
|
||||
// API rejects a request that replays those without declaring the tools
|
||||
// they refer to. tool_choice none is the sanctioned "answer in text" knob.
|
||||
const final = await getClient().messages.create({
|
||||
model,
|
||||
max_tokens: req.maxTokens,
|
||||
...(req.system ? { system: req.system } : {}),
|
||||
tools,
|
||||
tool_choice: { type: 'none' },
|
||||
messages,
|
||||
})
|
||||
return { text: textOf(final), model, usage: addUsage(usage, usageOf(final)) }
|
||||
|
||||
Reference in New Issue
Block a user