diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5c6d092be --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md (Project-Level) + +## Scope +This file applies to the `picoclaw` project root. + +## Known API Error: `No tool output found for function call` + +### Symptom +OpenAI Responses API returns HTTP 400 with message: +`No tool output found for function call `. + +### Meaning +The request includes a `function_call` item, but the next round input is missing the matching +`function_call_output` for the same `call_id`. + +### Project-specific root cause we fixed +In this repo, the failure was caused by history sanitization dropping valid tool outputs when +one assistant turn emitted multiple tool calls. + +- Fixed logic: `pkg/agent/context.go` (`sanitizeHistoryForProvider`) +- Behavior now: + - Tracks pending tool call IDs for an assistant tool-call turn. + - Preserves multiple consecutive tool outputs from the same turn. + - Drops truly orphaned tool outputs (unknown or empty `tool_call_id` when IDs are required). + +### Regression tests +- `pkg/agent/context_test.go` +- Run: + - `go test ./pkg/agent -run TestSanitizeHistoryForProvider -count=1` + +If toolchain/dependency download needs proxy in this environment, run: +- `source ~/.zshrc && proxy_on && go test ./pkg/agent -run TestSanitizeHistoryForProvider -count=1` + +## Docker Redeploy (Important: profiles are required) + +`docker-compose.yml` defines services under profiles (`agent`, `gateway`). +Running `docker compose up -d --build` without profile may fail with: +`no service selected`. + +### Gateway redeploy +1. `docker compose down` +2. `docker compose --profile gateway up -d --build` +3. `docker compose ps` +4. `curl -sS http://127.0.0.1:18790/health` + +Expected healthy state: +- container: `picoclaw-gateway` +- compose status: `Up ... (healthy)` +- health response contains `"status":"ok"` + +### Useful checks +- `docker logs --tail 100 picoclaw-gateway` +- `docker ps --filter name=picoclaw-gateway` + +## Proxy note for non-interactive shells + +`proxy_on` is a shell function defined in `~/.zshrc` (not a standalone binary). +In non-interactive command contexts, use: + +- `source ~/.zshrc && proxy_on && ` + diff --git a/Dockerfile b/Dockerfile index 480244127..03d3522a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,20 @@ # ============================================================ FROM golang:1.25-alpine AS builder +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$http_proxy \ + https_proxy=$https_proxy \ + no_proxy=$no_proxy + RUN apk add --no-cache git make WORKDIR /src @@ -20,11 +34,25 @@ RUN make build # ============================================================ FROM alpine:3.23 +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG NO_PROXY +ARG http_proxy +ARG https_proxy +ARG no_proxy + +ENV HTTP_PROXY=$HTTP_PROXY \ + HTTPS_PROXY=$HTTPS_PROXY \ + NO_PROXY=$NO_PROXY \ + http_proxy=$http_proxy \ + https_proxy=$https_proxy \ + no_proxy=$no_proxy + RUN apk add --no-cache ca-certificates tzdata curl # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -q --spider http://localhost:18790/health || exit 1 + CMD curl --silent --fail --noproxy '*' http://localhost:18790/health >/dev/null || exit 1 # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw diff --git a/docker-compose.yml b/docker-compose.yml index c268b01cd..465ed52cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,16 @@ services: build: context: . dockerfile: Dockerfile + network: host + args: + HTTP_PROXY: http://127.0.0.1:7890 + HTTPS_PROXY: http://127.0.0.1:7890 + NO_PROXY: localhost,127.0.0.1,::1 + http_proxy: http://127.0.0.1:7890 + https_proxy: http://127.0.0.1:7890 + no_proxy: localhost,127.0.0.1,::1 container_name: picoclaw-agent + network_mode: host profiles: - agent # Uncomment to access host network; leave commented unless needed. @@ -16,6 +25,15 @@ services: volumes: - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace + environment: + HTTP_PROXY: http://127.0.0.1:7890 + HTTPS_PROXY: http://127.0.0.1:7890 + NO_PROXY: localhost,127.0.0.1,::1 + http_proxy: http://127.0.0.1:7890 + https_proxy: http://127.0.0.1:7890 + no_proxy: localhost,127.0.0.1,::1 + ALL_PROXY: socks5://127.0.0.1:7890 + all_proxy: socks5://127.0.0.1:7890 entrypoint: ["picoclaw", "agent"] stdin_open: true tty: true @@ -28,7 +46,16 @@ services: build: context: . dockerfile: Dockerfile + network: host + args: + HTTP_PROXY: http://127.0.0.1:7890 + HTTPS_PROXY: http://127.0.0.1:7890 + NO_PROXY: localhost,127.0.0.1,::1 + http_proxy: http://127.0.0.1:7890 + https_proxy: http://127.0.0.1:7890 + no_proxy: localhost,127.0.0.1,::1 container_name: picoclaw-gateway + network_mode: host restart: unless-stopped profiles: - gateway @@ -40,6 +67,15 @@ services: - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro # Persistent workspace (sessions, memory, logs) - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace + environment: + HTTP_PROXY: http://127.0.0.1:7890 + HTTPS_PROXY: http://127.0.0.1:7890 + NO_PROXY: localhost,127.0.0.1,::1 + http_proxy: http://127.0.0.1:7890 + https_proxy: http://127.0.0.1:7890 + no_proxy: localhost,127.0.0.1,::1 + ALL_PROXY: socks5://127.0.0.1:7890 + all_proxy: socks5://127.0.0.1:7890 command: ["gateway"] volumes: diff --git a/pkg/agent/context.go b/pkg/agent/context.go index b7c6e1108..d8b887839 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -482,6 +482,8 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } sanitized := make([]providers.Message, 0, len(history)) + var pendingToolCalls map[string]struct{} + for _, msg := range history { switch msg.Role { case "system": @@ -493,29 +495,32 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message continue case "tool": - if len(sanitized) == 0 { - logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{}) - continue - } - // Walk backwards to find the nearest assistant message, - // skipping over any preceding tool messages (multi-tool-call case). - foundAssistant := false - for i := len(sanitized) - 1; i >= 0; i-- { - if sanitized[i].Role == "tool" { - continue - } - if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 { - foundAssistant = true - } - break - } - if !foundAssistant { + if pendingToolCalls == nil { logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{}) continue } + + // When tool call IDs are available, require exact call_id matches. + if len(pendingToolCalls) > 0 { + if msg.ToolCallID == "" { + logger.DebugCF("agent", "Dropping orphaned tool message with empty call id", map[string]any{}) + continue + } + if _, ok := pendingToolCalls[msg.ToolCallID]; !ok { + logger.DebugCF( + "agent", + "Dropping orphaned tool message with unknown call id", + map[string]any{"tool_call_id": msg.ToolCallID}, + ) + continue + } + delete(pendingToolCalls, msg.ToolCallID) + } sanitized = append(sanitized, msg) case "assistant": + pendingToolCalls = nil + if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) @@ -530,10 +535,18 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message ) continue } + + pendingToolCalls = make(map[string]struct{}, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + if tc.ID != "" { + pendingToolCalls[tc.ID] = struct{}{} + } + } } sanitized = append(sanitized, msg) default: + pendingToolCalls = nil sanitized = append(sanitized, msg) } } diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index e023c9c30..3429986dc 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -188,6 +188,52 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { assertRoles(t, result, "user", "assistant", "user", "assistant") } +func TestSanitizeHistoryForProvider_KeepMultipleToolOutputsFromOneAssistantTurn(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "check two files"}, + { + Role: "assistant", + ToolCalls: []providers.ToolCall{ + {ID: "call_1", Name: "read_file"}, + {ID: "call_2", Name: "read_file"}, + }, + }, + {Role: "tool", ToolCallID: "call_1", Content: "file a"}, + {Role: "tool", ToolCallID: "call_2", Content: "file b"}, + } + + got := sanitizeHistoryForProvider(history) + + if len(got) != 4 { + t.Fatalf("len(got) = %d, want 4; got=%#v", len(got), got) + } + if got[2].Role != "tool" || got[2].ToolCallID != "call_1" { + t.Fatalf("got[2] = %#v, want tool output for call_1", got[2]) + } + if got[3].Role != "tool" || got[3].ToolCallID != "call_2" { + t.Fatalf("got[3] = %#v, want tool output for call_2", got[3]) + } +} + +func TestSanitizeHistoryForProvider_DropToolOutputWithUnknownCallID(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + ToolCalls: []providers.ToolCall{ + {ID: "call_1", Name: "read_file"}, + }, + }, + {Role: "tool", ToolCallID: "call_999", Content: "orphan"}, + } + + got := sanitizeHistoryForProvider(history) + + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2; got=%#v", len(got), got) + } +} + func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) for i, m := range msgs { diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index f78197bbe..e74d87a18 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -76,6 +76,7 @@ func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry { client: &http.Client{ Timeout: timeout, Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, MaxIdleConns: 5, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, diff --git a/pkg/skills/clawhub_registry_test.go b/pkg/skills/clawhub_registry_test.go index 65ee638da..e29d3b281 100644 --- a/pkg/skills/clawhub_registry_test.go +++ b/pkg/skills/clawhub_registry_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "testing" "github.com/stretchr/testify/assert" @@ -149,6 +150,19 @@ func TestClawHubRegistryAuthToken(t *testing.T) { _, _ = reg.Search(context.Background(), "test", 5) } +func TestClawHubRegistryUsesProxyFromEnvironment(t *testing.T) { + reg := newTestRegistry("https://example.com", "") + + transport, ok := reg.client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.Proxy) + assert.Equal( + t, + reflect.ValueOf(http.ProxyFromEnvironment).Pointer(), + reflect.ValueOf(transport.Proxy).Pointer(), + ) +} + func TestExtractZipPathTraversal(t *testing.T) { // Create a ZIP with a path traversal entry. var buf bytes.Buffer