fix(claude-sdk): keep SDK loop alive when end_turn / react fails so the model can retry - #158
Closed
claudiusthebot wants to merge 1 commit into
Closed
fix(claude-sdk): keep SDK loop alive when end_turn / react fails so the model can retry#158claudiusthebot wants to merge 1 commit into
claudiusthebot wants to merge 1 commit into
Conversation
β¦he model can retry
PostToolBatch hook used to terminate the SDK query loop the moment it saw a
turn-terminator tool call in the batch, with zero check on whether the call
actually succeeded. When the terminator's delivery failed (e.g. Telegram
rejected `end_turn` for "Message too long", invalid chat_id, network blip),
the loop still exited as if delivery succeeded β the model saw the error in
its tool result but had no model turn left to react. End result: the user
saw nothing and the turn was silently dropped.
Concrete incident (2026-05-13 13:11Z Pandario reply 226264): an end-of-turn
delivery of a 4326-char message hit Telegram's 4096 cap, the bridge returned
`{ok: false, error: "Message too long..."}`, hook fired regardless, turn
ended. Dylan had to ping the bot to find out anything had happened.
Fix:
- New helper `isFailedToolResponse(response)` parses the bridge response
(raw `{ok: false}`, JSON string, or MCP content envelope `[{type, text}]`
around the JSON) and returns true iff failure is affirmatively detected.
- PostToolBatch hook now consults the terminator's `tool_response`. If
failure is detected, returns `{ continue: true }` and logs that the loop
is being preserved β model receives the error and can retry / message
the user about the problem.
- Conservative default preserved: any unrecognizable / missing / parse-
failed response keeps the current terminate-on-success perf path. Same
happy-path latency, no regression for tools that don't return ok-shaped
JSON.
Tests:
- 7 new PostToolBatch hook cases (end_turn fail object / JSON string / MCP
envelope, react fail, non-terminator-send pass-through, success still
terminates, missing / garbage response still terminates).
- 12 new unit tests for `isFailedToolResponse` covering every shape combo.
- 2015 total tests, 1 pre-existing flake in `package.functional.test.ts`
unrelated (same flake on main when running tests on a host where Talon
is already live β verified).
Collaborator
Author
|
Superseded by a cleaner approach β see follow-up PR. The content-sniffing hook in this PR is fragile (frontend coupling, schema drift, false positives). Replacing with: make |
6 tasks
dylanneve1
added a commit
that referenced
this pull request
May 19, 2026
β¦ SDK's native error pipeline (#159) When a turn-terminator tool (`end_turn`, strict `react`) failed to deliver (e.g. Telegram rejected `end_turn` for "Message too long", invalid chat_id, network blip), the PostToolBatch hook terminated the SDK loop anyway β the model saw the error in its tool result but had no turn left to react. End result: silent dropped turn, user sees nothing. Canonical incident (2026-05-13 13:11Z Pandario reply 226264): end-of-turn delivery of a 4326-char message hit Telegram's 4096 cap, bridge returned `{ok: false, error: "Message too long..."}`, hook fired regardless, turn silently ended. Supersedes #158 (content-sniffing approach was fragile β frontend-coupled, schema-drift-vulnerable, false-positive-prone on responses that happened to contain `"ok":false` substrings). This PR uses the SDK's NATIVE error pipeline instead of inspecting bodies. Implementation: 1. `end_turn.execute` and `react.execute` THROW when the bridge returns `{ok: false}` instead of returning the failure object silently. A new `throwIfFailed` helper wraps the bridge result and raises a typed `Error("<tool> delivery failed: <bridge error>")`. The "what counts as a failure" decision now lives in the tool implementation, where the contract is owned. 2. The SDK observes the throw and fires `PostToolUseFailure` with a typed `{tool_name, tool_input, tool_use_id, error, is_interrupt}` payload β no string sniffing, no `unknown` parsing. 3. New `PostToolUseFailure` hook records the failed `tool_use_id` in a per-session `Set<string>`. Ignores interrupts (`is_interrupt: true`) and non-terminator failures (e.g. `send`). 4. `PostToolBatch` hook now consults the Set β if the terminator's `tool_use_id` was flagged, it deletes the flag and returns `{continue: true}` to keep the SDK loop alive. Otherwise terminates as before (perf win from PR #122 preserved on the happy path). 5. The two hooks share state via closure β `buildTurnTerminatorHooks()` creates a fresh Set per `buildSdkOptions()` call, so concurrent chat sessions stay isolated. Frontend-agnostic by design: any frontend whose tools throw on delivery failure gets the same recovery behaviour. No bridge envelope shape is baked into the SDK options layer. Tests: - 9 new `PostToolUseFailure + PostToolBatch coordination` cases (terminator failure preserves loop, success terminates, interrupt ignored, non-terminator failure ignored, soft-react `end_turn:false` ignored, defensive non-failure events, flag-consumed-on-match, per-session isolation). - 8 new messaging-tools cases for `end_turn` / `react` throw behaviour (text path throws on {ok:false}, buttons path throws, generic message when error field missing, success path unchanged, react strict + soft both throw, react strips end_turn param). - All 33 existing PostToolBatch hook tests still pass. - 2001/2014 vitest pass β same pre-existing `package.functional` flake as PR #157 (irrelevant: running tests on a host where Talon daemon is already live). - typecheck clean, prettier clean, no new lint warnings. Co-authored-by: Dylan Neve <dylan.neve@intel.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
PostToolBatchhook used to terminate the SDK query loop the moment it saw a turn-terminator tool call in the batch β with zero check on whether the call actually succeeded. When the terminator's delivery failed (e.g. Telegram rejectedend_turnfor "Message too long", invalidchat_id, network blip), the loop exited as if delivery had worked. The model saw the error in its tool result but had no model turn left to react.End result: the user saw nothing and the turn was silently dropped.
Canonical incident
2026-05-13 13:11Z (Pandario reply 226264). I tried to deliver an end-of-turn of 4326 chars. Telegram's cap is 4096. Bridge returned
{ok: false, error: \"Message too long (4326 chars, max 4096).\"}. Hook fired regardless. Turn ended. User pinged the bot 24 min later asking what happened.Fix
New helper
isFailedToolResponse(response: unknown): booleanthat parses the bridge response in any of its three observed shapes (raw{ok: false}object, JSON string, MCP content envelope[{type:\"text\", text:\"<JSON>\"}]) and returnstrueiff failure is affirmatively detected.turnTerminatorHooknow consults the terminator'stool_response. If failure is detected, returns{ continue: true }and logs that the loop is being preserved β model receives the error in the next assistant turn and can retry / message the user about the problem.Conservative default preserved: any unrecognizable / missing / parse-failed response keeps the current terminate-on-success perf path. Same happy-path latency, no regression for tools that don't return ok-shaped JSON.
Test plan
PostToolBatch turn-terminator hook > preserves SDK loop on failed terminatorcases:end_turnfail as raw{ok:false}object β loop aliveend_turnfail as JSON string β loop aliveend_turnfail wrapped in MCP[{type, text}]envelope β loop alivereact(strict terminator) fail β loop alivesendfail β pass-through unchanged (hook never fires for it)end_turnsuccess β still terminates (no regression)end_turnwith missing / garbage response β still terminates (conservative default)isFailedToolResponsecovering every input shape:{ok:false}/{ok:true}\"{...}\"for bothnull/undefined/\"\"\"ok\"(cheap skip)\"ok\"okfieldnpm run typecheckcleannpm run lintβ no new warningsnpm testβ 2002/2015 pass, 1 pre-existing flake inpackage.functional.test.tsunrelated (same flake onmainwhen tests run on a host where Talon daemon is already live βstatusreturns "running" instead of "stopped").π€ Generated with Claude Code