feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos Correcting or reversing an entry that already sits 3+ links deep in a rattelse chain (correction_of_id/reverses_id walked in the DB, never description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the caller to book ONE correction expressing the chain's net effect. Agents looped storno+rattelse 10 deep on a live company (63/193 vouchers noise). The guard is advisory, never a dead end: allow_deep_chain bypasses it on every surface (correctEntry/reverseEntry option, REST body, MCP tool arg staged through pending_operations, and confirm dialogs with Ratta anda / Aterfor anda in the web UI). MCP staging pre-flight fires the guard at stage time so the agent reconsiders in the same turn, and the executor re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for the two bypass properties (trimmed to one sentence first). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): retry the Bedrock stream once on transient failures A transient stream death (429/5xx, transport cut, or the two known stream-corruption signatures: 'Unexpected event order' and 'request ended without sending any chunks') killed the whole chat turn, stranding the user mid-answer. The turn now retries once per turn after a short backoff: safe because nothing is persisted until finalMessage() succeeds. A new stream_restart event carries the pre-attempt text snapshot so the chat client resets the partial bubble, drops uncompleted tool chips, and shows 'Forsoker igen...' until the retried stream produces text. Non-transient errors (403, 400) keep the existing immediate-error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1 apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain, making references/journal-entries.md stale. Regenerated (hand-applied: the generator output is deterministic from the registry). While wiring: the v1 correct route validated allow_deep_chain but dropped it, and the v1 reverse route's strict body schema would have rejected it outright, leaving API clients no bypass when the chain-depth guard fires. Both now forward the flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-trigger CI after Vercel infra hang The preview for e527e4044 compiled in 91s then hung 40 minutes in the TypeScript phase and was killed with no error output; a CLI redeploy of the identical code went Ready in 5m. Empty commit to refresh the git- triggered deployment status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): address CodeRabbit review on the chain-depth guard - correction-chain: report rootVoucher only when the walk reached a genuine parentless root; a broken link, cycle, or hop-cap now yields null instead of presenting an intermediate voucher as the chain root. - recordate: propagate allow_deep_chain end-to-end (recordateEntry option, route schema, and a Flytta anda bypass confirm in the dialog); a date move is another storno+rattelse layer and carried the guard with no override path. - v1 correct/reverse: run the chain-depth guard before the dry-run return so a dry run gives the same verdict as the real execution. - dashboard reverse route: 400 on malformed JSON or a non-boolean allow_deep_chain instead of silently reversing without the override; empty body stays the supported no-body case. Tests added. - AgentChat stream_restart: discard the dead attempt's reasoning and re-arm the post-tool paragraph break so a retried turn doesn't render thinking twice or glue its continuation onto restored text. - v1 reverse route doc comment updated for allow_deep_chain. Not changed: the journal-list reverse flow (flagged as a dead end) can never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only for entries that are neither storno nor correction, and such entries have no backward chain links, so their depth is always 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): recordate route test expects the new options arg recordateEntry now takes { allowDeepChain } as a sixth argument; the route test's called-with assertion predates it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e494662530
commit
05380ddf54
@@ -256,6 +256,10 @@ export default function AgentChat({
|
||||
}
|
||||
}, [streaming, onStatus])
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
// True between a stream_restart event and the retried stream's first sign of
|
||||
// life: renders a discreet "Försöker igen…" line so the reset bubble doesn't
|
||||
// read as the assistant silently going blank.
|
||||
const [retryNotice, setRetryNotice] = useState(false)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
// Active turn's controller, kept in a ref (not state) so the stop button
|
||||
@@ -377,6 +381,7 @@ export default function AgentChat({
|
||||
|
||||
setStreaming(true)
|
||||
setErrorMessage(null)
|
||||
setRetryNotice(false)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -598,7 +603,31 @@ export default function AgentChat({
|
||||
})),
|
||||
)
|
||||
break
|
||||
case 'stream_restart': {
|
||||
// The server's model stream died on a transient error and is being
|
||||
// retried. Nothing from the dead attempt was persisted: reset the
|
||||
// in-progress bubble to the server's pre-attempt snapshot, drop tool
|
||||
// chips that never completed (eager chips from the dead stream), and
|
||||
// discard the dead attempt's reasoning so the retried attempt's
|
||||
// thinking doesn't render twice. The post-tool paragraph break is
|
||||
// re-armed only when restored text exists: the snapshot always ends
|
||||
// at an iteration boundary (after tool results), so the retried
|
||||
// continuation should open its own paragraph there.
|
||||
setRetryNotice(true)
|
||||
const restored = typeof ev.assistant_text === 'string' ? ev.assistant_text : ''
|
||||
breakBeforeNextTextRef.current = restored.length > 0
|
||||
setMessages((prev) =>
|
||||
updateLastAssistant(prev, (m) => ({
|
||||
...m,
|
||||
text: restored,
|
||||
reasoning: undefined,
|
||||
toolCalls: m.toolCalls?.filter((tc) => tc.completed),
|
||||
})),
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'text_delta':
|
||||
setRetryNotice(false)
|
||||
// Insert a paragraph break ONCE when text resumes after a tool
|
||||
// call, so post-tool narration starts on its own line instead of
|
||||
// gluing onto the previous sentence ("kategoriseras.Inget historik").
|
||||
@@ -706,9 +735,11 @@ export default function AgentChat({
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
setRetryNotice(false)
|
||||
setErrorMessage(ev.message as string)
|
||||
break
|
||||
case 'turn_complete': {
|
||||
setRetryNotice(false)
|
||||
if (!firstTurnFiredRef.current && conversationIdRef.current) {
|
||||
firstTurnFiredRef.current = true
|
||||
onFirstTurnComplete?.(conversationIdRef.current)
|
||||
@@ -811,6 +842,12 @@ export default function AgentChat({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{retryNotice && !errorMessage && (
|
||||
<div className="text-xs text-muted-foreground px-1">
|
||||
Anslutningen bröts, försöker igen…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
|
||||
Reference in New Issue
Block a user