From 6e1deb894594707edc38c971f677b7cd8e477572 Mon Sep 17 00:00:00 2001 From: mrigankad Date: Fri, 20 Feb 2026 15:58:04 +0530 Subject: [PATCH] feat: add macOS Catalina, ARMv7 support, and fix history compression --- .github/workflows/release.yml | 40 ++++++++++++++ .goreleaser.yaml | 6 +++ Makefile | 20 ++++++- README.md | 33 ++++++++++++ go.mod | 2 +- pkg/agent/context.go | 52 ++++++++++++++++--- pkg/agent/loop.go | 98 +++++++++++++++++++++++++++++++++++ pkg/channels/feishu_32.go | 2 +- pkg/channels/feishu_64.go | 2 +- 9 files changed, 244 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 786c893ef..334b6c3f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,3 +100,43 @@ jobs: gh release edit "${{ inputs.tag }}" \ --draft=${{ inputs.draft }} \ --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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2c47f7d86..022d75414 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,10 +31,16 @@ builds: - s390x - mips64 - arm + goarm: + - "7" main: ./cmd/picoclaw ignore: - goos: windows goarch: arm + - goos: darwin + goarch: arm + - goos: freebsd + goarch: arm dockers_v2: - id: picoclaw diff --git a/Makefile b/Makefile index ff280e3e4..501b140d3 100644 --- a/Makefile +++ b/Makefile @@ -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 BINARY_NAME=picoclaw @@ -86,12 +86,30 @@ build-all: generate @mkdir -p $(BUILD_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=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=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=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) @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: build @echo "Installing $(BINARY_NAME)..." diff --git a/README.md b/README.md index 468350409..be49d9019 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,39 @@ make build-all 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 You can also run PicoClaw using Docker Compose without installing anything locally. diff --git a/go.mod b/go.mod index 1f88639c8..634c83fcb 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ 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/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 27e3ef9dc..530b9ec86 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -214,31 +214,69 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } 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 { switch msg.Role { case "tool": - if len(sanitized) == 0 { - logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{}) + if lastCallAssistantIdx < 0 { + logger.DebugCF("agent", "Dropping orphaned tool message: no preceding tool-call assistant", + map[string]interface{}{"tool_call_id": msg.ToolCallID}) continue } - last := sanitized[len(sanitized)-1] - if last.Role != "assistant" || len(last.ToolCalls) == 0 { - logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{}) + // Any user message or any assistant message between the tool-call + // assistant and the current position means this result belongs to a + // 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 } + // 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) case "assistant": if len(msg.ToolCalls) > 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 } prev := sanitized[len(sanitized)-1] 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 } + // 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) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e7b48d47a..acbde668f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -773,6 +773,9 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { newHistory = append(newHistory, keptConversation...) 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 agent.Sessions.SetHistory(sessionKey, newHistory) agent.Sessions.Save(sessionKey) @@ -930,10 +933,105 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { if finalSummary != "" { agent.Sessions.SetSummary(sessionKey, finalSummary) 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) } } +// 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. 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" diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go index 4e60fbc11..f91e580a5 100644 --- a/pkg/channels/feishu_32.go +++ b/pkg/channels/feishu_32.go @@ -1,4 +1,4 @@ -//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 +//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 && !arm package channels diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go index 9e15fa3a7..02c499aee 100644 --- a/pkg/channels/feishu_64.go +++ b/pkg/channels/feishu_64.go @@ -1,4 +1,4 @@ -//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 || arm package channels