Skip to content

Commit 1ab34b9

Browse files
torrmalclaude
andcommitted
Stop relying on previous_response_id for tool-call continuation
Real-world failure: "LLM returned 400 Bad Request: ... No tool call found for function call output with call_id ...". The planning loop's continuation request set previous_response_id and sent only the new function_call_output items, trusting the provider to remember the matching function_call from the prior turn server-side. MindsHub (and presumably other OpenAI-compatible proxies that don't persist Responses API state) doesn't honor that, so the call_id was unrecognized. plan() now tracks its own transcript (the original task message plus every function_call/function_call_output pair) and resends it in full on each round instead of depending on server-side history. Drops the now-unused previous_response_id field. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent acc2848 commit 1ab34b9

3 files changed

Lines changed: 54 additions & 26 deletions

File tree

‎internal/agent/agent.go‎

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ type toolArguments struct {
3434
Query string `json:"query"`
3535
}
3636

37+
type inputMessage struct {
38+
Role string `json:"role"`
39+
Content string `json:"content"`
40+
}
41+
3742
type Runner struct {
3843
client *Client
3944
repository *repo.Repository
@@ -127,16 +132,28 @@ func (runner *Runner) route(ctx context.Context, task string) (routeDecision, er
127132
return decision, nil
128133
}
129134

135+
// plan drives the tool-calling loop by resending the full conversation
136+
// transcript on every round rather than relying on previous_response_id.
137+
// Not every OpenAI-compatible provider actually persists server-side
138+
// response state, and one that doesn't will reject a function_call_output
139+
// referencing a call_id it never stored, so the transcript is tracked here
140+
// instead.
130141
func (runner *Runner) plan(ctx context.Context, task, repoMap string) (Plan, string, error) {
131-
request := responseRequest{
132-
Instructions: planningInstructions,
133-
Input: fmt.Sprintf("TASK:\n%s\n\nREPOSITORY MAP:\n%s", task, repoMap),
134-
Tools: repositoryTools(),
135-
ToolChoice: "auto",
136-
Text: strictSchema("implementation_plan", planSchema()),
137-
}
142+
taskText := fmt.Sprintf("TASK:\n%s\n\nREPOSITORY MAP:\n%s", task, repoMap)
143+
var transcript []any
138144
var collected strings.Builder
139145
for round := 0; round < maxToolRounds; round++ {
146+
var input any = taskText
147+
if transcript != nil {
148+
input = transcript
149+
}
150+
request := responseRequest{
151+
Instructions: planningInstructions,
152+
Input: input,
153+
Tools: repositoryTools(),
154+
ToolChoice: "auto",
155+
Text: strictSchema("implementation_plan", planSchema()),
156+
}
140157
callCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
141158
response, err := runner.client.create(callCtx, request)
142159
cancel()
@@ -155,19 +172,14 @@ func (runner *Runner) plan(ctx context.Context, task, repoMap string) (Plan, str
155172
}
156173
return plan, collected.String(), nil
157174
}
158-
outputs := make([]toolOutput, 0, len(calls))
175+
if transcript == nil {
176+
transcript = []any{inputMessage{Role: "user", Content: taskText}}
177+
}
159178
for _, call := range calls {
160179
output := runner.runTool(ctx, call)
161180
fmt.Fprintf(&collected, "\nTOOL %s:\n%s\n", call.Name, output)
162-
outputs = append(outputs, toolOutput{Type: "function_call_output", CallID: call.CallID, Output: output})
163-
}
164-
request = responseRequest{
165-
Instructions: planningInstructions,
166-
PreviousResponseID: response.ID,
167-
Input: outputs,
168-
Tools: repositoryTools(),
169-
ToolChoice: "auto",
170-
Text: strictSchema("implementation_plan", planSchema()),
181+
transcript = append(transcript, call)
182+
transcript = append(transcript, toolOutput{Type: "function_call_output", CallID: call.CallID, Output: output})
171183
}
172184
}
173185
return Plan{}, "", fmt.Errorf("the model exceeded %d repository tool rounds", maxToolRounds)

‎internal/agent/agent_test.go‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,25 @@ func TestRunnerToolPlanPatchApply(t *testing.T) {
3030
case 2:
3131
fmt.Fprint(writer, `{"id":"resp_1","output":[{"type":"function_call","name":"read_files","call_id":"call_1","arguments":"{\"paths\":[\"hello.txt\"]}"}]}`)
3232
case 3:
33-
if body["previous_response_id"] != "resp_1" {
34-
t.Fatalf("previous_response_id = %v", body["previous_response_id"])
33+
// The continuation must resend the full transcript itself
34+
// rather than lean on previous_response_id: not every
35+
// OpenAI-compatible provider persists server-side response
36+
// state, and one that doesn't rejects an orphaned
37+
// function_call_output.
38+
if _, present := body["previous_response_id"]; present {
39+
t.Fatalf("previous_response_id must not be sent: %v", body["previous_response_id"])
40+
}
41+
input, ok := body["input"].([]any)
42+
if !ok || len(input) != 3 {
43+
t.Fatalf("input = %#v, want a 3-item transcript", body["input"])
44+
}
45+
call, ok := input[1].(map[string]any)
46+
if !ok || call["type"] != "function_call" || call["call_id"] != "call_1" {
47+
t.Fatalf("input[1] = %#v, want the echoed function_call", input[1])
48+
}
49+
output, ok := input[2].(map[string]any)
50+
if !ok || output["type"] != "function_call_output" || output["call_id"] != "call_1" {
51+
t.Fatalf("input[2] = %#v, want the matching function_call_output", input[2])
3552
}
3653
fmt.Fprint(writer, `{"id":"resp_2","output":[{"type":"message","content":[{"type":"output_text","text":"{\"summary\":\"Update greeting\",\"files_to_modify\":[\"hello.txt\"],\"context_files\":[],\"steps\":[\"Replace greeting\"]}"}]}]}`)
3754
case 4:

‎internal/agent/client.go‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@ type Client struct {
2020
}
2121

2222
type responseRequest struct {
23-
Model string `json:"model"`
24-
Instructions string `json:"instructions,omitempty"`
25-
Input any `json:"input"`
26-
PreviousResponseID string `json:"previous_response_id,omitempty"`
27-
Tools []functionTool `json:"tools,omitempty"`
28-
ToolChoice string `json:"tool_choice,omitempty"`
29-
Text *textConfig `json:"text,omitempty"`
23+
Model string `json:"model"`
24+
Instructions string `json:"instructions,omitempty"`
25+
Input any `json:"input"`
26+
Tools []functionTool `json:"tools,omitempty"`
27+
ToolChoice string `json:"tool_choice,omitempty"`
28+
Text *textConfig `json:"text,omitempty"`
3029
}
3130

3231
type functionTool struct {

0 commit comments

Comments
 (0)