feat: add macOS Catalina, ARMv7 support, and fix history compression

This commit is contained in:
mrigankad 2026-02-20 15:58:04 +05:30
parent f1223eec42
commit 6e1deb8945
9 changed files with 244 additions and 11 deletions

View file

@ -100,3 +100,43 @@ jobs:
gh release edit "${{ inputs.tag }}" \ gh release edit "${{ inputs.tag }}" \
--draft=${{ inputs.draft }} \ --draft=${{ inputs.draft }} \
--prerelease=${{ inputs.prerelease }} --prerelease=${{ inputs.prerelease }}
release-darwin-legacy:
name: Build darwin/amd64 (macOS 10.15+ / Catalina)
needs: [create-tag, release]
runs-on: macos-13
permissions:
contents: write
steps:
- name: Checkout tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.tag }}
- name: Setup Go from go.mod
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Build darwin/amd64 legacy binary
env:
CGO_ENABLED: "1"
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
run: make build-darwin-legacy
- name: Create archive
run: |
cd build
tar -czf picoclaw_Darwin_x86_64_catalina.tar.gz picoclaw-darwin-amd64-catalina
- name: Upload to release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ inputs.tag }}" \
build/picoclaw_Darwin_x86_64_catalina.tar.gz \
--clobber

View file

@ -31,10 +31,16 @@ builds:
- s390x - s390x
- mips64 - mips64
- arm - arm
goarm:
- "7"
main: ./cmd/picoclaw main: ./cmd/picoclaw
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: darwin
goarch: arm
- goos: freebsd
goarch: arm
dockers_v2: dockers_v2:
- id: picoclaw - id: picoclaw

View file

@ -1,4 +1,4 @@
.PHONY: all build install uninstall clean help test .PHONY: all build build-all build-darwin-legacy install uninstall clean help test
# Build variables # Build variables
BINARY_NAME=picoclaw BINARY_NAME=picoclaw
@ -86,12 +86,30 @@ build-all: generate
@mkdir -p $(BUILD_DIR) @mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@echo "All builds complete" @echo "All builds complete"
## build-darwin-legacy: Build darwin/amd64 binary compatible with macOS 10.15 (Catalina)+
## Requires building natively on a macOS host (Intel or Rosetta). Uses CGO with
## -mmacosx-version-min=10.15 so that macOS 12+ symbols are weak-linked and the
## binary runs on macOS 10.15 and later.
build-darwin-legacy: generate
@echo "Building $(BINARY_NAME) for darwin/amd64 (macOS 10.15+ compatible)..."
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 \
CGO_CFLAGS="-mmacosx-version-min=10.15" \
CGO_LDFLAGS="-mmacosx-version-min=10.15" \
MACOSX_DEPLOYMENT_TARGET=10.15 \
GOOS=darwin GOARCH=amd64 \
$(GO) build $(GOFLAGS) $(LDFLAGS) \
-o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64-catalina ./$(CMD_DIR)
@echo "Legacy darwin build complete: $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64-catalina"
## install: Install picoclaw to system and copy builtin skills ## install: Install picoclaw to system and copy builtin skills
install: build install: build
@echo "Installing $(BINARY_NAME)..." @echo "Installing $(BINARY_NAME)..."

View file

@ -149,6 +149,39 @@ make build-all
make install make install
``` ```
### macOS 10.15 (Catalina) Compatibility
> [!NOTE]
> Official precompiled Darwin x86_64 binaries target macOS 12.0+. A separate
> **`picoclaw_Darwin_x86_64_catalina.tar.gz`** asset is published with every
> release specifically for macOS 10.15 (Catalina) and later.
**Requirements for a local Catalina-compatible build:**
| Requirement | Version |
|---|---|
| macOS host | 10.15 or later (Intel or Rosetta) |
| Xcode Command Line Tools | any recent version |
| Go toolchain | bundled via `go.mod` (`go install`) |
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Produces build/picoclaw-darwin-amd64-catalina
make build-darwin-legacy
```
This target sets `CGO_ENABLED=1`, `CGO_CFLAGS=-mmacosx-version-min=10.15`, and
`MACOSX_DEPLOYMENT_TARGET=10.15` so that any macOS 12+ symbols are weak-linked
and the binary loads correctly on macOS 10.15.
> [!IMPORTANT]
> `make build-darwin-legacy` must be run on a **macOS host** (Intel or Apple
> Silicon with Rosetta). Cross-compiling this target from Linux is not supported
> because it requires Clang/Apple's linker.
## 🐳 Docker Compose ## 🐳 Docker Compose
You can also run PicoClaw using Docker Compose without installing anything locally. You can also run PicoClaw using Docker Compose without installing anything locally.

2
go.mod
View file

@ -27,7 +27,7 @@ require (
) )
require ( require (
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect

View file

@ -214,31 +214,69 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
} }
sanitized := make([]providers.Message, 0, len(history)) sanitized := make([]providers.Message, 0, len(history))
// Index (into sanitized) of the last assistant message that carried ToolCalls.
// -1 means no such message has been seen yet.
lastCallAssistantIdx := -1
for _, msg := range history { for _, msg := range history {
switch msg.Role { switch msg.Role {
case "tool": case "tool":
if len(sanitized) == 0 { if lastCallAssistantIdx < 0 {
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{}) logger.DebugCF("agent", "Dropping orphaned tool message: no preceding tool-call assistant",
map[string]interface{}{"tool_call_id": msg.ToolCallID})
continue continue
} }
last := sanitized[len(sanitized)-1] // Any user message or any assistant message between the tool-call
if last.Role != "assistant" || len(last.ToolCalls) == 0 { // assistant and the current position means this result belongs to a
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{}) // now-lost turn and should be dropped.
interveningTurn := false
for i := lastCallAssistantIdx + 1; i < len(sanitized); i++ {
if r := sanitized[i].Role; r == "user" || r == "assistant" {
interveningTurn = true
break
}
}
if interveningTurn {
logger.DebugCF("agent", "Dropping orphaned tool message: intervening turn",
map[string]interface{}{"tool_call_id": msg.ToolCallID})
continue continue
} }
// ID-based check: the ToolCallID must match one of the tool calls in
// the associated assistant message. This catches the case where a
// mid-compression cut left a result whose parent call was dropped but
// another assistant-with-calls happened to precede it in the kept half.
if msg.ToolCallID != "" {
found := false
for _, tc := range sanitized[lastCallAssistantIdx].ToolCalls {
if tc.ID == msg.ToolCallID {
found = true
break
}
}
if !found {
logger.DebugCF("agent", "Dropping orphaned tool message: ID mismatch",
map[string]interface{}{"tool_call_id": msg.ToolCallID})
continue
}
}
sanitized = append(sanitized, msg) sanitized = append(sanitized, msg)
case "assistant": case "assistant":
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]interface{}{}) logger.DebugCF("agent", "Dropping assistant tool-call turn at history start",
map[string]interface{}{})
continue continue
} }
prev := sanitized[len(sanitized)-1] prev := sanitized[len(sanitized)-1]
if prev.Role != "user" && prev.Role != "tool" { if prev.Role != "user" && prev.Role != "tool" {
logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]interface{}{"prev_role": prev.Role}) logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor",
map[string]interface{}{"prev_role": prev.Role})
continue continue
} }
// Record where this assistant lands so tool-result validation can
// reference it. len(sanitized) is the index after append.
lastCallAssistantIdx = len(sanitized)
} }
sanitized = append(sanitized, msg) sanitized = append(sanitized, msg)

View file

@ -773,6 +773,9 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
newHistory = append(newHistory, keptConversation...) newHistory = append(newHistory, keptConversation...)
newHistory = append(newHistory, history[len(history)-1]) // Last message newHistory = append(newHistory, history[len(history)-1]) // Last message
// Sanitize tool pairs in case the mid-point cut landed inside a paired turn.
newHistory = sanitizeToolPairs(newHistory)
// Update session // Update session
agent.Sessions.SetHistory(sessionKey, newHistory) agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey) agent.Sessions.Save(sessionKey)
@ -930,10 +933,105 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
if finalSummary != "" { if finalSummary != "" {
agent.Sessions.SetSummary(sessionKey, finalSummary) agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, 4) agent.Sessions.TruncateHistory(sessionKey, 4)
// The truncation window boundary may have landed inside a tool-call/result
// pair. Sanitize now so the persisted history is clean for every subsequent
// request and does not trigger provider API errors.
remaining := agent.Sessions.GetHistory(sessionKey)
if sanitized := sanitizeToolPairs(remaining); len(sanitized) != len(remaining) {
agent.Sessions.SetHistory(sessionKey, sanitized)
}
agent.Sessions.Save(sessionKey) agent.Sessions.Save(sessionKey)
} }
} }
// sanitizeToolPairs ensures every assistant message with ToolCalls has matching
// tool results, and every tool result has a corresponding tool_call in an
// assistant message. Orphaned messages are removed to prevent provider API
// errors (e.g. "tool_use ids were provided that do not have a tool_use block")
// that arise when history compression cuts in the middle of a paired turn.
//
// For assistant messages where only SOME tool calls have results, the unmatched
// calls are stripped from the ToolCalls slice while the rest of the message
// (including any text content) is preserved.
func sanitizeToolPairs(messages []providers.Message) []providers.Message {
if len(messages) == 0 {
return messages
}
// Pass 1: collect all tool_call IDs and tool_result IDs present.
toolCallIDs := make(map[string]bool)
toolResultIDs := make(map[string]bool)
for _, m := range messages {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
toolCallIDs[tc.ID] = true
}
}
if m.Role == "tool" && m.ToolCallID != "" {
toolResultIDs[m.ToolCallID] = true
}
}
// Pass 2: filter and repair.
result := make([]providers.Message, 0, len(messages))
dropped := 0
for _, m := range messages {
switch m.Role {
case "tool":
// Drop tool results whose tool_call is not present in any assistant message.
if m.ToolCallID != "" && !toolCallIDs[m.ToolCallID] {
dropped++
continue
}
result = append(result, m)
case "assistant":
if len(m.ToolCalls) == 0 {
result = append(result, m)
continue
}
// Partition ToolCalls by whether their result exists.
matched := make([]providers.ToolCall, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
if toolResultIDs[tc.ID] {
matched = append(matched, tc)
}
}
switch {
case len(matched) == len(m.ToolCalls):
result = append(result, m) // all matched: keep as-is
case len(matched) > 0:
// Partial match: keep message with only the matched calls.
stripped := m
stripped.ToolCalls = matched
result = append(result, stripped)
dropped += len(m.ToolCalls) - len(matched)
case m.Content != "":
// No matched calls but has text: keep text only.
result = append(result, providers.Message{Role: "assistant", Content: m.Content})
dropped += len(m.ToolCalls)
default:
// No content, no matched calls: drop entirely.
dropped++
}
default:
result = append(result, m)
}
}
if dropped > 0 {
logger.WarnCF("agent", "Sanitized orphaned tool pairs from history", map[string]interface{}{
"dropped_items": dropped,
"before": len(messages),
"after": len(result),
})
}
return result
}
// summarizeBatch summarizes a batch of messages. // summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) { func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"

View file

@ -1,4 +1,4 @@
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 //go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 && !arm
package channels package channels

View file

@ -1,4 +1,4 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 //go:build amd64 || arm64 || riscv64 || mips64 || ppc64 || arm
package channels package channels