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
@@ -28,7 +28,8 @@ vi.mock('@/lib/agent/ask/persist', () => ({
|
||||
persistAssistantTurn: (...a: unknown[]) => persistAssistant(...a),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { POST, maxDuration } from '../route'
|
||||
import { EmptyModelAnswerError } from '@/lib/agent/ask/errors'
|
||||
|
||||
const membershipChain = { select: () => membershipChain, eq: () => membershipChain, maybeSingle: async () => ({ data: { user_id: 'user-1' } }) }
|
||||
const supabase = { from: () => membershipChain }
|
||||
@@ -48,6 +49,9 @@ beforeEach(() => {
|
||||
const body = (o: Record<string, unknown> = {}) => ({ question: 'Hur gick juli?', ...o })
|
||||
|
||||
describe('POST /api/agent/ask', () => {
|
||||
it('declares a 300s function budget so a tool-loop answer is not killed mid-turn', () => {
|
||||
expect(maxDuration).toBe(300)
|
||||
})
|
||||
it('401 when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({ user: null, supabase, error: NextResponse.json({ error: 'x' }, { status: 401 }) })
|
||||
expect((await POST(createMockRequest('/api/agent/ask', { method: 'POST', body: body() }))).status).toBe(401)
|
||||
@@ -84,6 +88,15 @@ describe('POST /api/agent/ask', () => {
|
||||
expect(answer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('502s with a Swedish message when the model answers empty (the silent-stop bug)', async () => {
|
||||
answer.mockRejectedValue(new EmptyModelAnswerError('claude-sonnet-5'))
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: body() }))
|
||||
const { status, body: b } = await parseJsonResponse<{ error: string; code: string }>(res)
|
||||
expect(status).toBe(502)
|
||||
expect(b.error).toBe('Assistenten gav inget svar. Försök igen.')
|
||||
expect(b.code).toBe('empty_model_answer')
|
||||
})
|
||||
|
||||
it('stateless (no persist): never touches the conversation tables', async () => {
|
||||
await POST(createMockRequest('/x', { method: 'POST', body: body() }))
|
||||
expect(resolveConv).not.toHaveBeenCalled()
|
||||
@@ -154,6 +167,18 @@ describe('POST /api/agent/ask', () => {
|
||||
expect(persistAssistant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('502s on an empty answer, keeps the question, never persists a blank assistant turn', async () => {
|
||||
answer.mockRejectedValue(new EmptyModelAnswerError('claude-sonnet-5'))
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: body({ persist: true }) }))
|
||||
const { status, body: b } = await parseJsonResponse<{ error: string }>(res)
|
||||
expect(status).toBe(502)
|
||||
expect(b.error).toBe('Assistenten gav inget svar. Försök igen.')
|
||||
// The user turn is written before the model call (retry keeps the
|
||||
// question), but no empty assistant turn may ever land in the thread.
|
||||
expect(persistUser).toHaveBeenCalled()
|
||||
expect(persistAssistant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still 503s (no write) when no backend is configured', async () => {
|
||||
aiStatus.mockReturnValue({ configured: false })
|
||||
const res = await POST(createMockRequest('/x', { method: 'POST', body: body({ persist: true }) }))
|
||||
|
||||
@@ -8,7 +8,9 @@ import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { getAiStatus } from '@/lib/ai'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { answerAssistantQuestion } from '@/lib/agent/ask/ask-service'
|
||||
import { EmptyModelAnswerError } from '@/lib/agent/ask/errors'
|
||||
import {
|
||||
resolveChatConversation,
|
||||
persistUserTurn,
|
||||
@@ -22,6 +24,17 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
|
||||
// so a hosted deploy would silently lose its ledger tools.
|
||||
ensureInitialized()
|
||||
|
||||
const log = createLogger('api.agent.ask')
|
||||
|
||||
// A tool-loop answer can take minutes (several model turns, each preceded by
|
||||
// real report reads). The plan default cap would kill the function mid-answer
|
||||
// and the client would see a silent empty stop; 300 s matches the other
|
||||
// long-running model surfaces (app/api/receipt-hunt/run).
|
||||
export const maxDuration = 300
|
||||
|
||||
// The Swedish body AskConsole's !res.ok branch renders verbatim.
|
||||
const EMPTY_ANSWER_MESSAGE = 'Assistenten gav inget svar. Försök igen.'
|
||||
|
||||
/**
|
||||
* POST /api/agent/ask: a single-call, provider-agnostic assistant answer over a
|
||||
* bounded read-only tool loop.
|
||||
@@ -108,6 +121,14 @@ export async function POST(request: Request): Promise<Response> {
|
||||
})
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
if (err instanceof EmptyModelAnswerError) {
|
||||
// Already logged with model + usage by ask-service.
|
||||
return NextResponse.json(
|
||||
{ error: EMPTY_ANSWER_MESSAGE, code: 'empty_model_answer' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
log.error('assistant ask failed', err, { companyId, persist: false })
|
||||
return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -143,10 +164,21 @@ export async function POST(request: Request): Promise<Response> {
|
||||
tier: parsed.data.tier,
|
||||
})
|
||||
|
||||
// answerAssistantQuestion throws EmptyModelAnswerError on an empty answer,
|
||||
// so an empty assistant turn is never persisted: the thread keeps the
|
||||
// question (retryable) but records no blank reply.
|
||||
await persistAssistantTurn(supabase, conversationId, result.answer)
|
||||
|
||||
return NextResponse.json({ data: { ...result, conversation_id: conversationId } })
|
||||
} catch (err) {
|
||||
if (err instanceof EmptyModelAnswerError) {
|
||||
// Already logged with model + usage by ask-service.
|
||||
return NextResponse.json(
|
||||
{ error: EMPTY_ANSWER_MESSAGE, code: 'empty_model_answer' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
log.error('assistant ask failed', err, { companyId, persist: true })
|
||||
return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
|
||||
// agent tool registry which is populated by the mcp-server extension at load.
|
||||
ensureInitialized()
|
||||
|
||||
// Deep turns (thinking at high/xhigh effort, up to 12 tool iterations, a
|
||||
// 15-27k-token prompt prefix) can outlive the plan default duration cap; a
|
||||
// function killed mid-stream closes the NDJSON stream cleanly, which the chat
|
||||
// used to render as silent success. 300 s matches the other long-running model
|
||||
// surfaces (app/api/receipt-hunt/run, app/api/agent/ask).
|
||||
export const maxDuration = 300
|
||||
|
||||
// Hard cap on the per-turn user input. Generous for a chat composer (about
|
||||
// 5k words / 20 pages) but bounds Bedrock token cost if the rate limiter is
|
||||
// ever fail-open and a client floods large payloads.
|
||||
|
||||
Reference in New Issue
Block a user