bot: echo argless tool calls with an explicit empty input (#824)
The API requires tool_use.input on echoed assistant turns, but Go's
omitempty drops empty maps along with nil ones — so any tool the model
called with NO arguments (a bare issues.list in the research loop, live
on chat.paulca.com: "messages.1.content.1.tool_use.input: Field
required") produced a hard 400 on the loop's next call and the member
saw "I couldn't finish looking just now". Latent everywhere a loop
echoes content (research, tasks, builds); it fires whenever the model
skips a tool's optional args, so it was a dice roll per run.
anthBlock now marshals an argless tool_use with an explicit "input":{};
every other block shape is untouched (pinned by test).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+56
−0
internal/bot/anthropic.go+18 −0internal/bot/anthropic_argless_test.go+38 −0
diff --git a/internal/bot/anthropic.go b/internal/bot/anthropic.go| index 69544f6..d5902d4 100644 |
| --- a/internal/bot/anthropic.go |
| +++ b/internal/bot/anthropic.go |
| @@ -60,6 +60,24 @@ type anthBlock struct { |
| Data string `json:"data,omitempty"` // redacted_thinking payload |
| } |
| +// MarshalJSON closes an omitempty trap on the tool-loop echo: the API |
| +// REQUIRES tool_use.input to be present, but Go's omitempty drops empty maps |
| +// along with nil ones — so echoing a tool the model called with NO arguments |
| +// (a bare issues.list, say) produced an assistant turn whose tool_use carried |
| +// no input at all, a hard 400 ("tool_use.input: Field required") on the |
| +// loop's next call. An argless tool_use marshals with an explicit empty |
| +// input; every other block shape is untouched. |
| +func (b anthBlock) MarshalJSON() ([]byte, error) { |
| + type alias anthBlock // method-less: plain marshalling, no recursion |
| + if b.Type != "tool_use" || len(b.Input) > 0 { |
| + return json.Marshal(alias(b)) |
| + } |
| + return json.Marshal(struct { |
| + alias |
| + Input map[string]any `json:"input"` // shallower field wins: {} rides |
| + }{alias(b), map[string]any{}}) |
| +} |
| + |
| type anthCache struct { |
| Type string `json:"type"` // "ephemeral" |
| } |
diff --git a/internal/bot/anthropic_argless_test.go b/internal/bot/anthropic_argless_test.go| new file mode 100644 |
| index 0000000..ff871f7 |
| --- /dev/null |
| +++ b/internal/bot/anthropic_argless_test.go |
| @@ -0,0 +1,38 @@ |
| +package bot |
| + |
| +import ( |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestAnthBlockMarshalArglessToolUse pins the echo wire shape: a tool_use the |
| +// model called with NO arguments must round-trip with an explicit empty |
| +// input (the API 400s without it — "tool_use.input: Field required"), while |
| +// other block shapes never grow an input field. |
| +func TestAnthBlockMarshalArglessToolUse(t *testing.T) { |
| + argless, err := json.Marshal(anthBlock{Type: "tool_use", ID: "tu_1", Name: "issues.list"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(string(argless), `"input":{}`) { |
| + t.Fatalf("argless tool_use must carry an explicit empty input: %s", argless) |
| + } |
| + |
| + withArgs, _ := json.Marshal(anthBlock{Type: "tool_use", ID: "tu_2", Name: "fetch", |
| + Input: map[string]any{"url": "https://a.com/"}}) |
| + if !strings.Contains(string(withArgs), `"url":"https://a.com/"`) { |
| + t.Fatalf("tool args lost: %s", withArgs) |
| + } |
| + |
| + for _, b := range []anthBlock{ |
| + {Type: "text", Text: "hi"}, |
| + {Type: "thinking", Thinking: "hmm", Signature: "sig"}, |
| + {Type: "tool_result", ToolUseID: "tu_1", Content: "ok"}, |
| + } { |
| + out, _ := json.Marshal(b) |
| + if strings.Contains(string(out), `"input"`) { |
| + t.Fatalf("%s block grew an input field: %s", b.Type, out) |
| + } |
| + } |
| +} |