chore: sync local customizations

This commit is contained in:
root 2026-02-23 16:45:01 +08:00
parent 8a1fb03974
commit 4ddc0b7217
7 changed files with 217 additions and 18 deletions

61
AGENTS.md Normal file
View file

@ -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 <call_id>`.
### 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 && <your-command>`

View file

@ -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

View file

@ -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:

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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