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
|
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
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
@ -20,11 +34,25 @@ RUN make build
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM alpine:3.23
|
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
|
RUN apk add --no-cache ca-certificates tzdata curl
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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 binary
|
||||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,16 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
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
|
container_name: picoclaw-agent
|
||||||
|
network_mode: host
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
# Uncomment to access host network; leave commented unless needed.
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
|
|
@ -16,6 +25,15 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
- 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"]
|
entrypoint: ["picoclaw", "agent"]
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
@ -28,7 +46,16 @@ services:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
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
|
container_name: picoclaw-gateway
|
||||||
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
|
|
@ -40,6 +67,15 @@ services:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||||
# Persistent workspace (sessions, memory, logs)
|
# Persistent workspace (sessions, memory, logs)
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
- 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"]
|
command: ["gateway"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,8 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitized := make([]providers.Message, 0, len(history))
|
sanitized := make([]providers.Message, 0, len(history))
|
||||||
|
var pendingToolCalls map[string]struct{}
|
||||||
|
|
||||||
for _, msg := range history {
|
for _, msg := range history {
|
||||||
switch msg.Role {
|
switch msg.Role {
|
||||||
case "system":
|
case "system":
|
||||||
|
|
@ -493,29 +495,32 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
continue
|
continue
|
||||||
|
|
||||||
case "tool":
|
case "tool":
|
||||||
if len(sanitized) == 0 {
|
if pendingToolCalls == nil {
|
||||||
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 {
|
|
||||||
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
||||||
continue
|
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)
|
sanitized = append(sanitized, msg)
|
||||||
|
|
||||||
case "assistant":
|
case "assistant":
|
||||||
|
pendingToolCalls = nil
|
||||||
|
|
||||||
if len(msg.ToolCalls) > 0 {
|
if len(msg.ToolCalls) > 0 {
|
||||||
if len(sanitized) == 0 {
|
if len(sanitized) == 0 {
|
||||||
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
|
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
|
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)
|
sanitized = append(sanitized, msg)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
pendingToolCalls = nil
|
||||||
sanitized = append(sanitized, msg)
|
sanitized = append(sanitized, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,52 @@ func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
|
||||||
assertRoles(t, result, "user", "assistant", "user", "assistant")
|
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 {
|
func roles(msgs []providers.Message) []string {
|
||||||
r := make([]string, len(msgs))
|
r := make([]string, len(msgs))
|
||||||
for i, m := range msgs {
|
for i, m := range msgs {
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ func NewClawHubRegistry(cfg ClawHubConfig) *ClawHubRegistry {
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
MaxIdleConns: 5,
|
MaxIdleConns: 5,
|
||||||
IdleConnTimeout: 30 * time.Second,
|
IdleConnTimeout: 30 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -149,6 +150,19 @@ func TestClawHubRegistryAuthToken(t *testing.T) {
|
||||||
_, _ = reg.Search(context.Background(), "test", 5)
|
_, _ = 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) {
|
func TestExtractZipPathTraversal(t *testing.T) {
|
||||||
// Create a ZIP with a path traversal entry.
|
// Create a ZIP with a path traversal entry.
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue