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
@@ -16,6 +16,7 @@ vi.mock('../snapshot', () => ({
|
||||
}))
|
||||
|
||||
import { answerAssistantQuestion } from '../ask-service'
|
||||
import { EmptyModelAnswerError } from '../errors'
|
||||
|
||||
function supabaseWith(company: { name?: string; entity_type?: string } | null): SupabaseClient {
|
||||
const chain = {
|
||||
@@ -116,6 +117,22 @@ describe('answerAssistantQuestion', () => {
|
||||
expect(call.prompt).toContain('Status: momsregistrerad (momsperiod: quarterly).')
|
||||
})
|
||||
|
||||
it('defaults maxTokens to 5400 (the streaming chat reply ceiling; 1500 caused empty max_tokens answers)', async () => {
|
||||
await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?' })
|
||||
expect(generateText.mock.calls[0][0].maxTokens).toBe(5400)
|
||||
})
|
||||
|
||||
it('rejects with the typed empty-answer error when the model returns no visible text', async () => {
|
||||
generateText.mockResolvedValue({ text: ' \n', model: 'claude-sonnet-5', usage: {} })
|
||||
const promise = answerAssistantQuestion({
|
||||
supabase: supabaseWith(null),
|
||||
companyId: 'c1',
|
||||
question: 'Hej?',
|
||||
})
|
||||
await expect(promise).rejects.toBeInstanceOf(EmptyModelAnswerError)
|
||||
await expect(promise).rejects.toMatchObject({ code: 'empty_model_answer' })
|
||||
})
|
||||
|
||||
it('honours a custom maxSteps', async () => {
|
||||
buildLedgerTools.mockReturnValue([
|
||||
{ name: 'gnubok_get_vat_report', description: 'd', jsonSchema: {}, execute: vi.fn() },
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getAiService, type AiTier, type AiToolDef } from '@/lib/ai'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { EmptyModelAnswerError } from './errors'
|
||||
import { buildLedgerTools } from './ledger-tools'
|
||||
import { buildAssistantSnapshot } from './snapshot'
|
||||
|
||||
const log = createLogger('agent.ask')
|
||||
|
||||
/**
|
||||
* Provider-agnostic assistant answer over a bounded, read-only tool loop.
|
||||
*
|
||||
@@ -57,7 +61,13 @@ export interface AskResult {
|
||||
model: string
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_TOKENS = 1500
|
||||
// Matches MAX_TOKENS_NO_THINKING in lib/agent/composer/client.ts, the
|
||||
// streaming chat's reply-sized ceiling on the same model. The original 1500
|
||||
// cap was tighter than every other assistant surface and made stop_reason
|
||||
// max_tokens routine on tool-loop turns: the whole budget could be spent
|
||||
// before the first visible text block, so the model came back with no text at
|
||||
// all ("Tänker", then silence).
|
||||
const DEFAULT_MAX_TOKENS = 5400
|
||||
const DEFAULT_MAX_STEPS = 5
|
||||
const MAX_QUESTION_CHARS = 4000
|
||||
const MAX_CONTEXT_CHARS = 24_000
|
||||
@@ -137,5 +147,21 @@ export async function answerAssistantQuestion(req: AskRequest): Promise<AskResul
|
||||
maxTokens: req.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
...(tools.length > 0 ? { tools, maxSteps: req.maxSteps ?? DEFAULT_MAX_STEPS } : {}),
|
||||
})
|
||||
|
||||
// An empty answer is a failure, not a result. Passing it through is exactly
|
||||
// the silent-stop bug: the route would 200 and the console would append an
|
||||
// invisible bubble. Log loudly (model + usage tell us whether the budget was
|
||||
// spent on tool churn) and fail typed so the route can answer 502.
|
||||
if (result.text.trim().length === 0) {
|
||||
log.error('model returned an empty answer', {
|
||||
companyId: req.companyId,
|
||||
model: result.model,
|
||||
tier: req.tier ?? 'assistant',
|
||||
usage: result.usage,
|
||||
toolCount: tools.length,
|
||||
})
|
||||
throw new EmptyModelAnswerError(result.model)
|
||||
}
|
||||
|
||||
return { answer: result.text, model: result.model }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Typed failure for "the model returned no visible text": stop_reason
|
||||
* max_tokens with the whole budget spent before the first text block, or a
|
||||
* refusal with an empty body. Before this existed, an empty answer flowed
|
||||
* through /api/agent/ask as a 200 and rendered as an invisible bubble
|
||||
* ("Tänker", then silence).
|
||||
*
|
||||
* Lives in its own module so the API route and its tests can match on it
|
||||
* without importing ask-service's full dependency graph.
|
||||
*/
|
||||
export class EmptyModelAnswerError extends Error {
|
||||
readonly code = 'empty_model_answer'
|
||||
|
||||
constructor(model: string) {
|
||||
super(`Model ${model} returned an empty answer`)
|
||||
this.name = 'EmptyModelAnswerError'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user