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:
Jakob Wennberg
2026-08-24 13:19:15 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 78525bd391
commit 6aecbdc9b7
13 changed files with 409 additions and 7 deletions
+54 -3
View File
@@ -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)
})
})
+27 -2
View File
@@ -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)) }