Vectrum
Sign In

agent: an interrupted turn leaves no tool_use unanswered

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent e61fb26commit7b05038

Showing 4 changed files with 272 additions and 2 deletions.

+62−0internal/agent/interrupt.goaddedView File
@@ -0,0 +1,62 @@
1+ package agent
2+
3+ import "codey/internal/llm"
4+
5+ // interruptedResult is what a tool call that never finished sends back. It is
6+ // an error result rather than empty text because that is what happened, and
7+ // because the model reads it: told the call was stopped, it asks what to do
8+ // instead of assuming the tool returned nothing.
9+ const interruptedResult = "[interrupted: the turn was stopped before this tool finished]"
10+
11+ // answerDanglingToolCalls closes out a conversation that ends on an assistant
12+ // turn still asking for tools. Every tool_use must be followed by a
13+ // tool_result — the API rejects the whole request over a single missing one,
14+ // on every request that follows, which is why an interrupt that leaves one
15+ // behind poisons the session rather than merely ending a turn.
16+ //
17+ // done supplies the results of calls that did finish before the stop, so the
18+ // work already paid for is kept; the rest are marked interrupted. All of them
19+ // go back in one user message, as they would have unharmed.
20+ //
21+ // Hosted calls (server_tool_use) are deliberately skipped: the API ran them
22+ // and supplied the result itself, and a local tool_result for one of those ids
23+ // is an error of its own.
24+ func answerDanglingToolCalls(msgs []llm.Message, done []llm.ContentBlock) []llm.Message {
25+ if len(msgs) == 0 {
26+ return msgs
27+ }
28+ last := msgs[len(msgs)-1]
29+ if last.Role != llm.RoleAssistant {
30+ return msgs
31+ }
32+
33+ byID := make(map[string]llm.ContentBlock, len(done))
34+ for _, r := range done {
35+ byID[r.ToolUseID] = r
36+ }
37+
38+ var results []llm.ContentBlock
39+ for _, b := range last.Content {
40+ if b.Type != llm.BlockToolUse {
41+ continue
42+ }
43+ if r, ok := byID[b.ID]; ok {
44+ results = append(results, r)
45+ continue
46+ }
47+ results = append(results, llm.ToolResultBlock(b.ID, interruptedResult, true))
48+ }
49+ if len(results) == 0 {
50+ return msgs
51+ }
52+ return append(msgs, llm.Message{Role: llm.RoleUser, Content: results})
53+ }
54+
55+ // closeInterruptedTurn applies the repair to the live conversation, with the
56+ // results dispatch managed to produce before it was stopped.
57+ func (a *Agent) closeInterruptedTurn(done []llm.ContentBlock) {
58+ h := a.state.History()
59+ if repaired := answerDanglingToolCalls(h, done); len(repaired) != len(h) {
60+ a.state.Replace(repaired)
61+ }
62+ }
+193−0internal/agent/interrupt_test.goaddedView File
@@ -0,0 +1,193 @@
1+ package agent
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "codey/internal/llm"
13+ "codey/internal/metrics"
14+ "codey/internal/tools"
15+ )
16+
17+ // newInterruptibleAgent wires an agent whose "wait" tool blocks until the
18+ // context is cancelled, which is what pressing esc does to a turn.
19+ func newInterruptibleAgent(t *testing.T, f *pausableAPI, root string, started chan<- struct{}) *Agent {
20+ t.Helper()
21+ log := metrics.NewWriter(&bytes.Buffer{}, "test")
22+ state := NewState()
23+ reg := tools.NewRegistry(log).Register(
24+ tools.NewRead(root, state),
25+ tools.New("wait", "Blocks until the turn is cancelled", func(ctx context.Context, _ struct{}) (string, error) {
26+ started <- struct{}{}
27+ <-ctx.Done()
28+ return "", ctx.Err()
29+ }),
30+ )
31+ client := llm.New(llm.WithBaseURL(f.srv.URL), llm.WithAPIKey("k"))
32+ return New(client, reg, state, log, Options{})
33+ }
34+
35+ // unanswered returns the tool_use ids in msgs that no tool_result answers. The
36+ // API rejects the whole request over even one of them, so this is the only
37+ // property that matters about an interrupted conversation.
38+ func unanswered(msgs []llm.Message) []string {
39+ answered := map[string]bool{}
40+ for _, m := range msgs {
41+ for _, b := range m.Content {
42+ if b.Type == llm.BlockToolResult {
43+ answered[b.ToolUseID] = true
44+ }
45+ }
46+ }
47+ var open []string
48+ for _, m := range msgs {
49+ for _, b := range m.Content {
50+ if b.Type == llm.BlockToolUse && !answered[b.ID] {
51+ open = append(open, b.ID)
52+ }
53+ }
54+ }
55+ return open
56+ }
57+
58+ // Interrupting a turn while a tool is in flight must still leave a conversation
59+ // the API will accept: the assistant message asking for the call is already in
60+ // the history, and a tool_use with no tool_result is a 400 on every request
61+ // that follows — the next prompt, or a --resume weeks later.
62+ func TestInterruptedTurnAnswersEveryToolCall(t *testing.T) {
63+ root := t.TempDir()
64+ os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644)
65+
66+ f := newPausableAPI(t,
67+ msg("tool_use",
68+ toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`),
69+ toolUseBlock("tu_2", "wait", `{}`)),
70+ msg("end_turn", textBlock("ok")),
71+ )
72+ started := make(chan struct{}, 1)
73+ a := newInterruptibleAgent(t, f, root, started)
74+
75+ ctx, cancel := context.WithCancel(context.Background())
76+ done := make(chan error, 1)
77+ go func() {
78+ _, err := a.Run(ctx, "go")
79+ done <- err
80+ }()
81+
82+ select {
83+ case <-started:
84+ case <-time.After(2 * time.Second):
85+ t.Fatal("the blocking tool never started")
86+ }
87+ cancel()
88+ select {
89+ case <-done:
90+ case <-time.After(2 * time.Second):
91+ t.Fatal("the cancelled run never returned")
92+ }
93+
94+ h := a.State().History()
95+ if open := unanswered(h); len(open) != 0 {
96+ t.Fatalf("after an interrupt these tool_use ids have no tool_result: %v", open)
97+ }
98+
99+ last := h[len(h)-1]
100+ if last.Role != llm.RoleUser || len(last.Content) != 2 {
101+ t.Fatalf("last message = %+v, want one user message holding both results", last)
102+ }
103+ // The call that did finish keeps what it returned: an interrupt throws
104+ // away the turn, not the work the turn already did.
105+ if got := last.Content[0]; got.ToolUseID != "tu_1" || got.IsError || !strings.Contains(got.Content, "A") {
106+ t.Errorf("result for the finished read = %+v, want its contents", got)
107+ }
108+ if got := last.Content[1]; got.ToolUseID != "tu_2" || !got.IsError || !strings.Contains(got.Content, "interrupted") {
109+ t.Errorf("result for the interrupted call = %+v, want an error saying it was interrupted", got)
110+ }
111+ }
112+
113+ // The repaired conversation is not merely well formed in memory: the next
114+ // prompt in the same session has to reach the API.
115+ func TestTheTurnAfterAnInterruptIsSent(t *testing.T) {
116+ root := t.TempDir()
117+ os.WriteFile(filepath.Join(root, "a.go"), []byte("A\n"), 0o644)
118+
119+ f := newPausableAPI(t,
120+ msg("tool_use", toolUseBlock("tu_1", "wait", `{}`)),
121+ msg("end_turn", textBlock("ok")),
122+ )
123+ started := make(chan struct{}, 1)
124+ a := newInterruptibleAgent(t, f, root, started)
125+
126+ ctx, cancel := context.WithCancel(context.Background())
127+ done := make(chan struct{})
128+ go func() {
129+ a.Run(ctx, "go")
130+ close(done)
131+ }()
132+ select {
133+ case <-started:
134+ case <-time.After(2 * time.Second):
135+ t.Fatal("the blocking tool never started")
136+ }
137+ cancel()
138+ select {
139+ case <-done:
140+ case <-time.After(2 * time.Second):
141+ t.Fatal("the cancelled run never returned")
142+ }
143+
144+ if _, err := a.Run(context.Background(), "continue pls"); err != nil {
145+ t.Fatalf("the prompt after an interrupt failed: %v", err)
146+ }
147+ if open := unanswered(f.lastRequest(t).Messages); len(open) != 0 {
148+ t.Errorf("the request sent after an interrupt left these tool_use ids unanswered: %v", open)
149+ }
150+ }
151+
152+ // A session saved while a turn was in flight is on disk with the dangling
153+ // tool_use in it. Reopening it must repair the history rather than hand the
154+ // user a 400 on their first word.
155+ func TestRestoreClosesADanglingToolUse(t *testing.T) {
156+ f := newPausableAPI(t, msg("end_turn", textBlock("ok")))
157+ a := newInterruptibleAgent(t, f, t.TempDir(), make(chan struct{}, 1))
158+
159+ a.Restore(Session{Messages: []llm.Message{
160+ {Role: llm.RoleUser, Content: []llm.ContentBlock{{Type: llm.BlockText, Text: "go"}}},
161+ {Role: llm.RoleAssistant, Content: []llm.ContentBlock{
162+ {Type: llm.BlockToolUse, ID: "tu_1", Name: "wait", Input: []byte(`{}`)},
163+ }},
164+ }})
165+
166+ h := a.State().History()
167+ if open := unanswered(h); len(open) != 0 {
168+ t.Fatalf("restoring left these tool_use ids unanswered: %v", open)
169+ }
170+ if last := h[len(h)-1]; last.Role != llm.RoleUser || last.Content[0].ToolUseID != "tu_1" {
171+ t.Errorf("last message = %+v, want a tool_result for tu_1", last)
172+ }
173+ }
174+
175+ // A hosted web search is answered by the API itself, in the same assistant
176+ // message. Closing it out locally would send a tool_result for an id the model
177+ // never asked us to run — an error of its own.
178+ func TestRestoreLeavesHostedToolCallsAlone(t *testing.T) {
179+ f := newPausableAPI(t, msg("end_turn", textBlock("ok")))
180+ a := newInterruptibleAgent(t, f, t.TempDir(), make(chan struct{}, 1))
181+
182+ restored := []llm.Message{
183+ {Role: llm.RoleUser, Content: []llm.ContentBlock{{Type: llm.BlockText, Text: "search"}}},
184+ {Role: llm.RoleAssistant, Content: []llm.ContentBlock{
185+ {Type: llm.BlockServerToolUse, ID: "srvtoolu_1", Name: "web_search", Input: []byte(`{}`)},
186+ }},
187+ }
188+ a.Restore(Session{Messages: restored})
189+
190+ if got := a.State().History(); len(got) != len(restored) {
191+ t.Errorf("history has %d messages, want the %d restored unchanged", len(got), len(restored))
192+ }
193+ }
+11−1internal/agent/loop.goView File
@@ -714,6 +714,13 @@ func (a *Agent) drive(ctx context.Context) (string, error) {
714714 case llm.StopToolUse:
715715 results, err := a.dispatch(ctx, turn, resp.ToolUses())
716716 if err != nil {
717+ // The turn was stopped part-way through its tool calls — esc,
718+ // ctrl-c, a killed subagent. The assistant message asking for
719+ // them is already in the history, so leaving now would strand
720+ // a tool_use nothing ever answers, and the API refuses every
721+ // request that follows over it: the next prompt, or a resume
722+ // weeks later. Close the calls out before going.
723+ a.closeInterruptedTurn(results)
717724 return "", err
718725 }
719726 // Every tool_result for this assistant turn goes back in ONE user
@@ -775,7 +782,10 @@ func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock
775782
776783 out, err := a.authorizedExecute(a.watching(ctx, turn, call), call)
777784 if ctx.Err() != nil {
778- return nil, ctx.Err()
785+ // The results collected so far travel out with the error: the
786+ // calls that did finish keep what they returned, and the caller
787+ // closes out the ones that did not.
788+ return results, ctx.Err()
779789 }
780790
781791 isErr := err != nil
+6−1internal/agent/session.goView File
@@ -221,8 +221,13 @@ func ListSessions(dir, root string) ([]SessionInfo, error) {
221221 }
222222
223223 // Restore seeds the agent's state from a saved session.
224+ //
225+ // A session that was interrupted mid-turn by an older build, or saved while a
226+ // turn was still in flight, is on disk with a tool_use nothing answers. It is
227+ // repaired on the way in: the alternative is a 400 on the first thing the user
228+ // types into a conversation that looks perfectly healthy on screen.
224229 func (a *Agent) Restore(s Session) {
225- a.state.Replace(s.Messages)
230+ a.state.Replace(answerDanglingToolCalls(s.Messages, nil))
226231 a.state.RestoreUsage(s.Usage, s.Turns)
227232 // The last request's size is unknown after a restore; the next turn's
228233 // response re-establishes it before compaction is consulted again.