chore: sync local customizations
This commit is contained in:
parent
8a1fb03974
commit
4ddc0b7217
7 changed files with 217 additions and 18 deletions
61
AGENTS.md
Normal file
61
AGENTS.md
Normal 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>`
|
||||
|
||||
30
Dockerfile
30
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue