fix(core): repair dangling tool_use on every request, not just load
A turn aborted mid-flight (cancel, connection drop, dev-server ECONNREFUSED) can leave an assistant tool_use block with no matching tool_result in the live transcript. repairToolUseResultPairs already fixes this, but only ran in OpenSession (load time), so an in-process abort left the transcript broken until restart. The next request was then rejected by Anthropic/OpenAI with 'tool_use ids were found without tool_result blocks'. Run the same repair on the outbound messages in oneTurn. It is pure and a no-op on valid transcripts, so there is no hot-path cost beyond a single linear scan and no behavior change for healthy sessions.
- commit
- b25b860b0988dad734f065f9ab1d337ccec357b4
- author
- patriceckhart · · 2026-06-09 10:56 UTC
- parent
- f7bf4a9
- signature
- unsigned
1 file · +11 -3
M packages/core/agent.go +11 -3
| @@ -502,9 +502,17 @@ func (a *Agent) dropLastAssistantMessage() { | |||
| 502 | 502 | // and the assembled assistant message (already appended to the transcript). | |
| 503 | 503 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | |
| 504 | 504 | req := provider.Request{ | |
| 505 | - | Model: a.Model, | |
| 506 | - | System: a.System, | |
| 507 | - | Messages: a.Messages(), | |
| 505 | + | Model: a.Model, | |
| 506 | + | System: a.System, | |
| 507 | + | // Repair any dangling tool_use blocks before sending. A turn | |
| 508 | + | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | |
| 509 | + | // dev server, etc.) can leave an assistant tool_use with no | |
| 510 | + | // matching tool_result in the live transcript. The load-time | |
| 511 | + | // repair in OpenSession only runs on restart, so without this the | |
| 512 | + | // next in-process request is rejected by providers like Anthropic | |
| 513 | + | // with "tool_use ids were found without tool_result blocks". The | |
| 514 | + | // repair is pure and a no-op on already-valid transcripts. | |
| 515 | + | Messages: repairToolUseResultPairs(a.Messages()), | |
| 508 | 516 | Tools: a.Tools.Specs(), | |
| 509 | 517 | Reasoning: a.Reasoning, | |
| 510 | 518 | } | |
| @@ -502,9 +502,17 @@ func (a *Agent) dropLastAssistantMessage() { | |||||
| 502 | // and the assembled assistant message (already appended to the transcript). | 502 | // and the assembled assistant message (already appended to the transcript). | ||
| 503 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | 503 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | ||
| 504 | req := provider.Request{ | 504 | req := provider.Request{ | ||
| 505 | - | Model: a.Model, | 505 | + | Model: a.Model, |
| 506 | - | System: a.System, | 506 | + | System: a.System, |
| 507 | - | Messages: a.Messages(), | 507 | + | // Repair any dangling tool_use blocks before sending. A turn |
| 508 | + | // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a | |||
| 509 | + | // dev server, etc.) can leave an assistant tool_use with no | |||
| 510 | + | // matching tool_result in the live transcript. The load-time | |||
| 511 | + | // repair in OpenSession only runs on restart, so without this the | |||
| 512 | + | // next in-process request is rejected by providers like Anthropic | |||
| 513 | + | // with "tool_use ids were found without tool_result blocks". The | |||
| 514 | + | // repair is pure and a no-op on already-valid transcripts. | |||
| 515 | + | Messages: repairToolUseResultPairs(a.Messages()), | |||
| 508 | Tools: a.Tools.Specs(), | 516 | Tools: a.Tools.Specs(), | ||
| 509 | Reasoning: a.Reasoning, | 517 | Reasoning: a.Reasoning, | ||
| 510 | } | 518 | } | ||