eleven / ocho Public

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>
Paul Campbell 3f9e58041704b04262da84ebe5bd89005188f0b9 parent f3d0bf3
2 files changed, +56 −0
  • internal/bot/anthropic.go +18 −0
  • internal/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)
+ }
+ }
+}