diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..d632da5ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +build/ +.picoclaw/ +config/ +.env +.env.example +*.md +LICENSE +assets/ diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..66539b634 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# ── LLM Provider ────────────────────────── +# Uncomment and set the API key for your provider +# OPENROUTER_API_KEY=sk-or-v1-xxx +# ZHIPU_API_KEY=xxx +# ANTHROPIC_API_KEY=sk-ant-xxx +# OPENAI_API_KEY=sk-xxx +# GEMINI_API_KEY=xxx + +# ── Chat Channel ────────────────────────── +# TELEGRAM_BOT_TOKEN=123456:ABC... +# DISCORD_BOT_TOKEN=xxx +# LINE_CHANNEL_SECRET=xxx +# LINE_CHANNEL_ACCESS_TOKEN=xxx + +# ── Web Search (optional) ──────────────── +# BRAVE_SEARCH_API_KEY=BSA... + +# ── Timezone ────────────────────────────── +TZ=Asia/Tokyo diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..4be385b22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report a bug or unexpected behavior +title: "[BUG]" +labels: bug +assignees: '' + +--- + +## Quick Summary + +## Environment & Tools +- **PicoClaw Version:** (e.g., v0.1.2 or commit hash) +- **Go Version:** (e.g., go 1.22) +- **AI Model & Provider:** (e.g., GPT-4o via OpenAI / DeepSeek via SiliconFlow) +- **Operating System:** (e.g., Ubuntu 22.04 / macOS / Android Termux) +- **Channels:** (e.g., Discord, Telegram, Feishu, ...) + +## 📸 Steps to Reproduce +1. +2. +3. + +## ❌ Actual Behavior + +## ✅ Expected Behavior + +## 💬 Additional Context diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..d3df0e79c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest a new idea or improvement +title: "[Feature]" +labels: enhancement +assignees: '' + +--- + +## 🎯 The Goal / Use Case + +## 💡 Proposed Solution + +## 🛠 Potential Implementation (Optional) + +## 🚦 Impact & Roadmap Alignment +- [ ] This is a Core Feature +- [ ] This is a Nice-to-Have / Enhancement +- [ ] This aligns with the current Roadmap + +## 🔄 Alternatives Considered + +## 💬 Additional Context diff --git a/.github/ISSUE_TEMPLATE/general-task---todo.md b/.github/ISSUE_TEMPLATE/general-task---todo.md new file mode 100644 index 000000000..eab70c030 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general-task---todo.md @@ -0,0 +1,26 @@ +--- +name: General Task / Todo +about: A specific piece of work like doc, refactoring, or maintenance. +title: "[Task]" +labels: '' +assignees: '' + +--- + +## 📝 Objective + +## 📋 To-Do List +- [ ] Step 1 +- [ ] Step 2 +- [ ] Step 3 + +## 🎯 Definition of Done (Acceptance Criteria) +- [ ] Documentation is updated in the README/docs folder. +- [ ] Code follows project linting standards. +- [ ] (If applicable) Basic tests pass. + +## 💡 Context / Motivation + +## 🔗 Related Issues / PRs +- Fixes # +- Relates to # diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..c96b7da12 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,43 @@ +## 📝 Description + + + +## 🗣️ Type of Change +- [ ] 🐞 Bug fix (non-breaking change which fixes an issue) +- [ ] ✨ New feature (non-breaking change which adds functionality) +- [ ] 📖 Documentation update +- [ ] ⚡ Code refactoring (no functional changes, no api changes) + +## 🤖 AI Code Generation +- [ ] 🤖 Fully AI-generated (100% AI, 0% Human) +- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified) +- [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none) + + +## 🔗 Related Issue + + + +## 📚 Technical Context (Skip for Docs) +- **Reference URL:** +- **Reasoning:** + +## 🧪 Test Environment +- **Hardware:** +- **OS:** +- **Model/Provider:** +- **Channels:** + + +## 📸 Evidence (Optional) +
+Click to view Logs/Screenshots + + + +
+ +## ☑️ Checklist +- [ ] My code/docs follow the style of this project. +- [ ] I have performed a self-review of my own changes. +- [ ] I have updated the documentation accordingly. \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..499613625 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: build + +on: + push: + branches: ["main"] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: fmt + run: | + make fmt + git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1) + + - name: Build + run: make build-all diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..dadbed212 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,76 @@ +name: 🐳 Build & Push Docker Image + +on: + workflow_call: + inputs: + tag: + description: "Release tag" + required: true + type: string + +env: + GHCR_REGISTRY: ghcr.io + GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw + DOCKERHUB_REGISTRY: docker.io + DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + +jobs: + build: + name: 🏗️ Build Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + # ── Checkout ────────────────────────────── + - name: 📥 Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag }} + + # ── Docker Buildx ───────────────────────── + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # ── Login to GHCR ───────────────────────── + - name: 🔑 Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.GHCR_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # ── Login to Docker Hub ──────────────────── + - name: 🔑 Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKERHUB_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # ── Metadata (tags & labels) ────────────── + - name: 🏷️ Prepare image tags + id: tags + shell: bash + run: | + tag="${{ inputs.tag }}" + echo "ghcr_tag=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT" + echo "ghcr_latest=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" + echo "dockerhub_tag=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT" + echo "dockerhub_latest=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT" + + # ── Build & Push ────────────────────────── + - name: 🚀 Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.tags.outputs.ghcr_tag }} + ${{ steps.tags.outputs.ghcr_latest }} + ${{ steps.tags.outputs.dockerhub_tag }} + ${{ steps.tags.outputs.dockerhub_latest }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64,linux/riscv64 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000..55bf77e00 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,82 @@ +name: PR + +on: + pull_request: { } + +jobs: + lint: + name: Linter + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run go generate + run: go generate ./... + + - name: Golangci Lint + uses: golangci/golangci-lint-action@v9 + with: + version: v2.10.1 + + # TODO: Remove once linter is properly configured + fmt-check: + name: Formatting + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Check formatting + run: | + make fmt + git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1) + + # TODO: Remove once linter is properly configured + vet: + name: Vet + runs-on: ubuntu-latest + needs: fmt-check + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run go generate + run: go generate ./... + + - name: Run go vet + run: go vet ./... + + test: + name: Tests + runs-on: ubuntu-latest + needs: fmt-check + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Run go generate + run: go generate ./... + + - name: Run go test + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..786c893ef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Create Tag and Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (required, e.g. v0.2.0)" + required: true + type: string + prerelease: + description: "Mark as pre-release" + required: false + type: boolean + default: false + draft: + description: "Create as draft" + required: false + type: boolean + default: false + +jobs: + create-tag: + name: Create Git Tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Create and push tag + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" + git push origin "$RELEASE_TAG" + + release: + name: GoReleaser Release + needs: create-tag + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - name: Checkout tag + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ inputs.tag }} + + - name: Setup Go from go.mod + id: setup-go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: ~> v2 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + GOVERSION: ${{ steps.setup-go.outputs.go-version }} + + - name: Apply release flags + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release edit "${{ inputs.tag }}" \ + --draft=${{ inputs.draft }} \ + --prerelease=${{ inputs.prerelease }} diff --git a/.gitignore b/.gitignore index 6ad4d78d6..ce30d749e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Binaries +# Go build artifacts bin/ +build/ *.exe *.dll *.so @@ -8,14 +10,23 @@ bin/ *.out /picoclaw /picoclaw-test +cmd/picoclaw/workspace # Picoclaw specific + +# PicoClaw .picoclaw/ config.json sessions/ build/ # Coverage + +# Secrets & Config (keep templates, ignore actual secrets) +.env +config/config.json + +# Test coverage.txt coverage.html @@ -24,3 +35,12 @@ coverage.html # Ralph workspace ralph/ +.ralph/ +tasks/ + +# Editors +.vscode/ +.idea/ + +# Added by goreleaser init: +dist/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 000000000..80e54ac1c --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,184 @@ +version: "2" + +linters: + default: all + disable: + # TODO: Tweak for current project needs + - containedctx + - cyclop + - depguard + - dupl + - dupword + - err113 + - exhaustruct + - funcorder + - gochecknoglobals + - godot + - intrange + - ireturn + - nlreturn + - noctx + - noinlineerr + - nonamedreturns + - tagliatelle + - testpackage + - varnamelen + - wrapcheck + - wsl + - wsl_v5 + + # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step) + - bodyclose + - contextcheck + - dogsled + - embeddedstructfieldcheck + - errcheck + - errchkjson + - errorlint + - exhaustive + - forbidigo + - forcetypeassert + - funlen + - gochecknoinits + - gocognit + - goconst + - gocritic + - gocyclo + - godox + - goprintffuncname + - gosec + - govet + - ineffassign + - lll + - maintidx + - misspell + - mnd + - modernize + - nakedret + - nestif + - nilnil + - paralleltest + - perfsprint + - prealloc + - predeclared + - revive + - staticcheck + - tagalign + - testifylint + - thelper + - unparam + - unused + - usestdlibvars + - usetesting + - wastedassign + - whitespace + settings: + errcheck: + check-type-assertions: true + check-blank: true + exhaustive: + default-signifies-exhaustive: true + funlen: + lines: 120 + statements: 40 + gocognit: + min-complexity: 25 + gocyclo: + min-complexity: 20 + govet: + enable-all: true + disable: + - fieldalignment + lll: + line-length: 120 + tab-width: 4 + misspell: + locale: US + mnd: + checks: + - argument + - assign + - case + - condition + - operation + - return + nakedret: + max-func-lines: 3 + revive: + enable-all-rules: true + rules: + - name: add-constant + disabled: true + - name: argument-limit + arguments: + - 7 + severity: warning + - name: banned-characters + disabled: true + - name: cognitive-complexity + disabled: true + - name: comment-spacings + arguments: + - nolint + severity: warning + - name: cyclomatic + disabled: true + - name: file-header + disabled: true + - name: function-result-limit + arguments: + - 3 + severity: warning + - name: function-length + disabled: true + - name: line-length-limit + disabled: true + - name: max-public-structs + disabled: true + - name: modifies-value-receiver + disabled: true + - name: package-comments + disabled: true + - name: unused-receiver + disabled: true + exclusions: + generated: lax + rules: + - linters: + - lll + source: '^//go:generate ' + - linters: + - funlen + - maintidx + - gocognit + - gocyclo + path: _test\.go$ + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +formatters: + enable: + - goimports + # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step) + # - gci + # - gofmt + # - gofumpt + # - golines + settings: + gci: + sections: + - standard + - default + - localmodule + custom-order: true + gofmt: + simplify: true + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + - pattern: "a[b:len(a)]" + replacement: "a[b:]" + golines: + max-len: 120 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 000000000..2c47f7d86 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,87 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj +version: 2 + +before: + hooks: + - go mod tidy + - go generate ./cmd/picoclaw + +builds: + - id: picoclaw + env: + - CGO_ENABLED=0 + tags: + - stdjson + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.gitCommit={{ .ShortCommit }} + - -X main.buildTime={{ .Date }} + - -X main.goVersion={{ .Env.GOVERSION }} + goos: + - linux + - windows + - darwin + - freebsd + goarch: + - amd64 + - arm64 + - riscv64 + - s390x + - mips64 + - arm + main: ./cmd/picoclaw + ignore: + - goos: windows + goarch: arm + +dockers_v2: + - id: picoclaw + dockerfile: Dockerfile.goreleaser + ids: + - picoclaw + images: + - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" + - "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}" + tags: + - "{{ .Tag }}" + - "latest" + platforms: + - linux/amd64 + - linux/arm64 + - linux/riscv64 + +archives: + - formats: [tar.gz] + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + formats: [zip] + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +# upx: +# - enabled: true +# compress: best +# lzma: true + +release: + footer: >- + + --- + + Released by [GoReleaser](https://github.com/goreleaser/goreleaser). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..0360cfda6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# ============================================================ +# Stage 1: Build the picoclaw binary +# ============================================================ +FROM golang:1.26.0-alpine AS builder + +RUN apk add --no-cache git make + +WORKDIR /src + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build +COPY . . +RUN make build + +# ============================================================ +# Stage 2: Minimal runtime image +# ============================================================ +FROM alpine:3.23 + +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 + +# Copy binary +COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw + +# Create non-root user and group +RUN addgroup -g 1000 picoclaw && \ + adduser -D -u 1000 -G picoclaw picoclaw + +# Switch to non-root user +USER picoclaw + +# Run onboard to create initial directories and config +RUN /usr/local/bin/picoclaw onboard + +ENTRYPOINT ["picoclaw"] +CMD ["gateway"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 000000000..0cdc8c6bd --- /dev/null +++ b/Dockerfile.goreleaser @@ -0,0 +1,10 @@ +FROM alpine:3.21 + +ARG TARGETPLATFORM + +RUN apk add --no-cache ca-certificates tzdata + +COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw + +ENTRYPOINT ["picoclaw"] +CMD ["gateway"] diff --git a/Makefile b/Makefile index 7babf6c9b..ff280e3e4 100644 --- a/Makefile +++ b/Makefile @@ -8,13 +8,14 @@ MAIN_GO=$(CMD_DIR)/main.go # Version VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" +LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w" # Go variables GO?=go -GOFLAGS?=-v +GOFLAGS?=-v -tags stdjson # Installation INSTALL_PREFIX?=$(HOME)/.local @@ -38,6 +39,8 @@ ifeq ($(UNAME_S),Linux) ARCH=amd64 else ifeq ($(UNAME_M),aarch64) ARCH=arm64 + else ifeq ($(UNAME_M),loongarch64) + ARCH=loong64 else ifeq ($(UNAME_M),riscv64) ARCH=riscv64 else @@ -62,22 +65,30 @@ BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) # Default target all: build +## generate: Run generate +generate: + @echo "Run generate..." + @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true + @$(GO) generate ./... + @echo "Run generate complete" + ## build: Build the picoclaw binary for current platform -build: +build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) ## build-all: Build picoclaw for all platforms -build-all: +build-all: generate @echo "Building for multiple platforms..." @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=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" @@ -88,35 +99,8 @@ install: build @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME) @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME) @echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)" - @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..." - @mkdir -p $(WORKSPACE_SKILLS_DIR) - @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \ - if [ -d "$$skill" ]; then \ - skill_name=$$(basename "$$skill"); \ - if [ -f "$$skill/SKILL.md" ]; then \ - cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \ - echo " ✓ Installed skill: $$skill_name"; \ - fi; \ - fi; \ - done @echo "Installation complete!" -## install-skills: Install builtin skills to workspace -install-skills: - @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..." - @mkdir -p $(WORKSPACE_SKILLS_DIR) - @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \ - if [ -d "$$skill" ]; then \ - skill_name=$$(basename "$$skill"); \ - if [ -f "$$skill/SKILL.md" ]; then \ - mkdir -p $(WORKSPACE_SKILLS_DIR)/$$skill_name; \ - cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \ - echo " ✓ Installed skill: $$skill_name"; \ - fi; \ - fi; \ - done - @echo "Skills installation complete!" - ## uninstall: Remove picoclaw from system uninstall: @echo "Uninstalling $(BINARY_NAME)..." @@ -138,15 +122,31 @@ clean: @rm -rf $(BUILD_DIR) @echo "Clean complete" +## vet: Run go vet for static analysis +vet: + @$(GO) vet ./... + +## fmt: Format Go code +test: + @$(GO) test ./... + ## fmt: Format Go code fmt: @$(GO) fmt ./... -## deps: Update dependencies +## deps: Download dependencies deps: + @$(GO) mod download + @$(GO) mod verify + +## update-deps: Update dependencies +update-deps: @$(GO) get -u ./... @$(GO) mod tidy +## check: Run vet, fmt, and verify dependencies +check: deps fmt vet test + ## run: Build and run picoclaw run: build @$(BUILD_DIR)/$(BINARY_NAME) $(ARGS) diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 000000000..7da16565f --- /dev/null +++ b/README.ja.md @@ -0,0 +1,775 @@ +
+PicoClaw + +

PicoClaw: Go で書かれた超効率 AI アシスタント

+ +

$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!

+

+ +

+Go +Hardware +License +

+ +[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) + +
+ + +--- + +🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。 + +⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い! + + + + + + +
+

+ +

+
+

+ +

+
+ +## 📢 ニュース +2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ! + +## ✨ 特徴 + +🪶 **超軽量**: メモリフットプリント 10MB 未満 — Clawdbot のコア機能より 99% 小さい。 + +💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。 + +⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。 + +🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go! + +🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。 + +| | OpenClaw | NanoBot | **PicoClaw** | +| --- | --- | --- |--- | +| **言語** | TypeScript | Python | **Go** | +| **RAM** | >1GB |>100MB| **< 10MB** | +| **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | +| **コスト** | Mac Mini 599$ | 大半の Linux SBC
~50$ |**あらゆる Linux ボード**
**最安 10$** | +PicoClaw + + +## 🦾 デモンストレーション +### 🛠️ スタンダードアシスタントワークフロー + + + + + + + + + + + + + + + + +

🧩 フルスタックエンジニア

🗂️ ログ&計画管理

🔎 Web 検索&学習

開発 · デプロイ · スケールスケジュール · 自動化 · メモリ発見 · インサイト · トレンド
+ +### 🐜 革新的な省フットプリントデプロイ +PicoClaw はほぼすべての Linux デバイスにデプロイできます! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) または $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) スマート監視に + +https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 + +🌟 もっと多くのデプロイ事例が待っています! + +## 📦 インストール + +### コンパイル済みバイナリでインストール + +[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のファームウェアをダウンロードしてください。 + +### ソースからインストール(最新機能、開発向け推奨) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# ビルド(インストール不要) +make build + +# 複数プラットフォーム向けビルド +make build-all + +# ビルドとインストール +make install +``` + +## 🐳 Docker Compose + +Docker Compose を使えば、ローカルにインストールせずに PicoClaw を実行できます。 + +```bash +# 1. リポジトリをクローン +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. API キーを設定 +cp config/config.example.json config/config.json +vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定 + +# 3. ビルドと起動 +docker compose --profile gateway up -d + +# 4. ログ確認 +docker compose logs -f picoclaw-gateway + +# 5. 停止 +docker compose --profile gateway down +``` + +### Agent モード(ワンショット) + +```bash +# 質問を投げる +docker compose run --rm picoclaw-agent -m "What is 2+2?" + +# インタラクティブモード +docker compose run --rm picoclaw-agent +``` + +### リビルド + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 クイックスタート(ネイティブ) + +> [!TIP] +> `~/.picoclaw/config.json` に API キーを設定してください。 +> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料) + +**1. 初期化** + +```bash +picoclaw onboard +``` + +**2. 設定** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +**3. API キーの取得** + +- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト) + +> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。 + +**3. チャット** + +```bash +picoclaw agent -m "What is 2+2?" +``` + +これだけです!2 分で AI アシスタントが動きます。 + +--- + +## 💬 チャットアプリ + +Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます + +| チャネル | セットアップ | +|---------|------------| +| **Telegram** | 簡単(トークンのみ) | +| **Discord** | 簡単(Bot トークン + Intents) | +| **QQ** | 簡単(AppID + AppSecret) | +| **DingTalk** | 普通(アプリ認証情報) | +| **LINE** | 普通(認証情報 + Webhook URL) | + +
+Telegram(推奨) + +**1. Bot を作成** + +- Telegram を開き、`@BotFather` を検索 +- `/newbot` を送信、プロンプトに従う +- トークンをコピー + +**2. 設定** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> ユーザー ID は Telegram の `@userinfobot` から取得できます。 + +**3. 起動** + +```bash +picoclaw gateway +``` +
+ + +
+Discord + +**1. Bot を作成** +- https://discord.com/developers/applications にアクセス +- アプリケーションを作成 → Bot → Add Bot +- Bot トークンをコピー + +**2. Intents を有効化** +- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化 +- (任意)**SERVER MEMBERS INTENT** も有効化 + +**3. ユーザー ID を取得** +- Discord 設定 → 詳細設定 → **開発者モード** を有効化 +- 自分のアバターを右クリック → **ユーザーIDをコピー** + +**4. 設定** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Bot を招待** +- OAuth2 → URL Generator +- Scopes: `bot` +- Bot Permissions: `Send Messages`, `Read Message History` +- 生成された招待 URL を開き、サーバーに Bot を追加 + +**6. 起動** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Bot を作成** + +- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス +- アプリケーションを作成 → **AppID** と **AppSecret** を取得 + +**2. 設定** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。 + +**3. 起動** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Bot を作成** + +- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス +- 内部アプリを作成 +- Client ID と Client Secret をコピー + +**2. 設定** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。 + +**3. 起動** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. LINE 公式アカウントを作成** + +- [LINE Developers Console](https://developers.line.biz/) にアクセス +- プロバイダーを作成 → Messaging API チャネルを作成 +- **チャネルシークレット** と **チャネルアクセストークン** をコピー + +**2. 設定** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Webhook URL を設定** + +LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください: + +```bash +# ngrok の例 +ngrok http 18791 +``` + +LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。 + +**4. 起動** + +```bash +picoclaw gateway +``` + +> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。 + +> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。 + +
+ +## ⚙️ 設定 + +設定ファイル: `~/.picoclaw/config.json` + +### ワークスペース構成 + +PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: + +``` +~/.picoclaw/workspace/ +├── sessions/ # 会話セッションと履歴 +├── memory/ # 長期メモリ(MEMORY.md) +├── state/ # 永続状態(最後のチャネルなど) +├── cron/ # スケジュールジョブデータベース +├── skills/ # カスタムスキル +├── AGENTS.md # エージェントの行動ガイド +├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認) +├── IDENTITY.md # エージェントのアイデンティティ +├── SOUL.md # エージェントのソウル +├── TOOLS.md # ツールの説明 +└── USER.md # ユーザー設定 +``` + +### 🔒 セキュリティサンドボックス + +PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。 + +#### デフォルト設定 + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| オプション | デフォルト | 説明 | +|-----------|-----------|------| +| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ | +| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 | + +#### 保護対象ツール + +`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます: + +| ツール | 機能 | 制限 | +|-------|------|------| +| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ | +| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ | +| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ | +| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ | +| `append_file` | ファイル追記 | ワークスペース内のファイルのみ | +| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり | + +#### exec ツールの追加保護 + +`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします: + +- `rm -rf`, `del /f`, `rmdir /s` — 一括削除 +- `format`, `mkfs`, `diskpart` — ディスクフォーマット +- `dd if=` — ディスクイメージング +- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み +- `shutdown`, `reboot`, `poweroff` — システムシャットダウン +- フォークボム `:(){ :|:& };:` + +#### エラー例 + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### 制限の無効化(セキュリティリスク) + +エージェントにワークスペース外のパスへのアクセスが必要な場合: + +**方法1: 設定ファイル** +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**方法2: 環境変数** +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。 + +#### セキュリティ境界の一貫性 + +`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます: + +| 実行パス | セキュリティ境界 | +|---------|-----------------| +| メインエージェント | `restrict_to_workspace` ✅ | +| サブエージェント / Spawn | 同じ制限を継承 ✅ | +| ハートビートタスク | 同じ制限を継承 ✅ | + +すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。 + +### ハートビート(定期タスク) + +PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します: + +```markdown +# 定期タスク + +- 重要なメールをチェック +- 今後の予定を確認 +- 天気予報をチェック +``` + +エージェントは30分ごと(設定可能)にこのファイルを読み込み、利用可能なツールを使ってタスクを実行します。 + +#### spawn で非同期タスク実行 + +時間のかかるタスク(Web検索、API呼び出し)には `spawn` ツールを使って**サブエージェント**を作成します: + +```markdown +# 定期タスク + +## クイックタスク(直接応答) +- 現在時刻を報告 + +## 長時間タスク(spawn で非同期) +- AIニュースを検索して要約 +- メールをチェックして重要なメッセージを報告 +``` + +**主な特徴:** + +| 機能 | 説明 | +|------|------| +| **spawn** | 非同期サブエージェントを作成、ハートビートをブロックしない | +| **独立コンテキスト** | サブエージェントは独自のコンテキストを持ち、セッション履歴なし | +| **message ツール** | サブエージェントは message ツールで直接ユーザーと通信 | +| **非ブロッキング** | spawn 後、ハートビートは次のタスクへ継続 | + +#### サブエージェントの通信方法 + +``` +ハートビート発動 + ↓ +エージェントが HEARTBEAT.md を読む + ↓ +長いタスク: spawn サブエージェント + ↓ ↓ +次のタスクへ継続 サブエージェントが独立して動作 + ↓ ↓ +全タスク完了 message ツールを使用 + ↓ ↓ +HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る +``` + +サブエージェントはツール(message、web_search など)にアクセスでき、メインエージェントを経由せずにユーザーと通信できます。 + +**設定:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| オプション | デフォルト | 説明 | +|-----------|-----------|------| +| `enabled` | `true` | ハートビートの有効/無効 | +| `interval` | `30` | チェック間隔(分)、最小5分 | + +**環境変数:** +- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更 + +### 基本設定 + +1. **設定ファイルの作成:** + + ```bash + cp config.example.json config/config.json + ``` + +2. **設定の編集:** + + ```json + { + "providers": { + "openrouter": { + "api_key": "sk-or-v1-..." + } + }, + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_DISCORD_BOT_TOKEN" + } + } + } + ``` + +3. **実行** + + ```bash + picoclaw agent -m "Hello" + ``` + + +
+完全な設定例 + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "apiKey": "sk-or-v1-xxx" + }, + "groq": { + "apiKey": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "appId": "cli_xxx", + "appSecret": "xxx", + "encryptKey": "", + "verificationToken": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "search": { + "apiKey": "BSA..." + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## CLI リファレンス + +| コマンド | 説明 | +|---------|------| +| `picoclaw onboard` | 設定&ワークスペースの初期化 | +| `picoclaw agent -m "..."` | エージェントとチャット | +| `picoclaw agent` | インタラクティブチャットモード | +| `picoclaw gateway` | ゲートウェイを起動 | +| `picoclaw status` | ステータスを表示 | + +## 🤝 コントリビュート&ロードマップ + +PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗 + +Discord: https://discord.gg/V4sAZ9XWpN + +PicoClaw + + +## 🐛 トラブルシューティング + +### Web 検索で「API 設定の問題」と表示される + +検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。 + +Web 検索を有効にするには: +1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料) +2. `~/.picoclaw/config.json` に追加: + ```json + { + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + } + } + } + ``` + +### コンテンツフィルタリングエラーが出る + +一部のプロバイダー(Zhipu など)にはコンテンツフィルタリングがあります。クエリを言い換えるか、別のモデルを使用してください。 + +### Telegram Bot で「Conflict: terminated by other getUpdates」と表示される + +別のインスタンスが実行中の場合に発生します。`picoclaw gateway` が 1 つだけ実行されていることを確認してください。 + +--- + +## 📝 API キー比較 + +| サービス | 無料枠 | ユースケース | +|---------|--------|------------| +| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) | +| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 | +| **Brave Search** | 月 2000 クエリ | Web 検索機能 | +| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) | diff --git a/README.md b/README.md index bc8543eaa..adeb5117b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@
-PicoClaw + PicoClaw -

PicoClaw: Ultra-Efficient AI Assistant in Go

+

PicoClaw: Ultra-Efficient AI Assistant in Go

-

$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!

-

+

$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!

-

-Go -Hardware -License -

+

+ Go + Hardware + License +
+ Website + Twitter +

+ [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **English**
--- @@ -35,9 +38,23 @@ -## 📢 News +> [!CAUTION] +> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** +> +> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**. +> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** +> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. +> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release. +> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state. -2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 皮皮虾,我们走! + +## 📢 News +2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board! + +2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. +🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. + +2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! ## ✨ Features @@ -51,12 +68,12 @@ 🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement. -| | OpenClaw | NanoBot | **PicoClaw** | -| --- | --- | --- |--- | -| **Language** | TypeScript | Python | **Go** | -| **RAM** | >1GB |>100MB| **< 10MB** | -| **Startup**
(0.8GHz core) | >500s | >30s | **<1s** | -| **Cost** | Mac Mini 599$ | Most Linux SBC
~50$ |**Any Linux Board**
**As low as 10$** | +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Language** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **Startup**
(0.8GHz core) | >500s | >30s | **<1s** | +| **Cost** | Mac Mini 599$ | Most Linux SBC
~50$ | **Any Linux Board**
**As low as 10$** | PicoClaw @@ -82,11 +99,25 @@ +### 📱 Run on old Android Phones +Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start: +1. **Install Termux** (Available on F-Droid or Google Play). +2. **Execute cmds** +```bash +# Note: Replace v0.1.1 with the latest version from the Releases page +wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 +chmod +x picoclaw-linux-arm64 +pkg install proot +termux-chroot ./picoclaw-linux-arm64 onboard +``` +And then follow the instructions in the "Quick Start" section to complete the configuration! +PicoClaw + ### 🐜 Innovative Low-Footprint Deploy PicoClaw can be deployed on almost any Linux device! -- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant - $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance - $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring @@ -118,12 +149,52 @@ make build-all make install ``` +## 🐳 Docker Compose + +You can also run PicoClaw using Docker Compose without installing anything locally. + +```bash +# 1. Clone this repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Set your API keys +cp config/config.example.json config/config.json +vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc. + +# 3. Build & Start +docker compose --profile gateway up -d + +# 4. Check logs +docker compose logs -f picoclaw-gateway + +# 5. Stop +docker compose --profile gateway down +``` + +### Agent Mode (One-shot) + +```bash +# Ask a question +docker compose run --rm picoclaw-agent -m "What is 2+2?" + +# Interactive mode +docker compose run --rm picoclaw-agent +``` + +### Rebuild + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + ### 🚀 Quick Start > [!TIP] > Set your API key in `~/.picoclaw/config.json`. > Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) +> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. **1. Initialize** @@ -152,9 +223,14 @@ picoclaw onboard }, "tools": { "web": { - "search": { + "brave": { + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 } } } @@ -163,8 +239,8 @@ picoclaw onboard **3. Get API Keys** -- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) +* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) > **Note**: See `config.example.json` for a complete configuration template. @@ -180,23 +256,24 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, or DingTalk +Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE -| Channel | Setup | -|---------|-------| -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **QQ** | Easy (AppID + AppSecret) | -| **DingTalk** | Medium (app credentials) | +| Channel | Setup | +| ------------ | ---------------------------------- | +| **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | +| **QQ** | Easy (AppID + AppSecret) | +| **DingTalk** | Medium (app credentials) | +| **LINE** | Medium (credentials + webhook URL) |
Telegram (Recommended) **1. Create a bot** -- Open Telegram, search `@BotFather` -- Send `/newbot`, follow prompts -- Copy the token +* Open Telegram, search `@BotFather` +* Send `/newbot`, follow prompts +* Copy the token **2. Configure** @@ -206,7 +283,7 @@ Talk to your picoclaw through Telegram, Discord, or DingTalk "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -227,19 +304,19 @@ picoclaw gateway **1. Create a bot** -- Go to -- Create an application → Bot → Add Bot -- Copy the bot token +* Go to +* Create an application → Bot → Add Bot +* Copy the bot token **2. Enable intents** -- In the Bot settings, enable **MESSAGE CONTENT INTENT** -- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data +* In the Bot settings, enable **MESSAGE CONTENT INTENT** +* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data **3. Get your User ID** -- Discord Settings → Advanced → enable **Developer Mode** -- Right-click your avatar → **Copy User ID** +* Discord Settings → Advanced → enable **Developer Mode** +* Right-click your avatar → **Copy User ID** **4. Configure** @@ -249,7 +326,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -257,10 +334,10 @@ picoclaw gateway **5. Invite the bot** -- OAuth2 → URL Generator -- Scopes: `bot` -- Bot Permissions: `Send Messages`, `Read Message History` -- Open the generated invite URL and add the bot to your server +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Open the generated invite URL and add the bot to your server **6. Run** @@ -275,7 +352,7 @@ picoclaw gateway **1. Create a bot** -- Go to [QQ Open Platform](https://connect.qq.com/) +- Go to [QQ Open Platform](https://q.qq.com/#) - Create an application → Get **AppID** and **AppSecret** **2. Configure** @@ -308,9 +385,9 @@ picoclaw gateway **1. Create a bot** -- Go to [Open Platform](https://open.dingtalk.com/) -- Create an internal app -- Copy Client ID and Client Secret +* Go to [Open Platform](https://open.dingtalk.com/) +* Create an internal app +* Copy Client ID and Client Secret **2. Configure** @@ -337,6 +414,62 @@ picoclaw gateway
+
+LINE + +**1. Create a LINE Official Account** + +- Go to [LINE Developers Console](https://developers.line.biz/) +- Create a provider → Create a Messaging API channel +- Copy **Channel Secret** and **Channel Access Token** + +**2. Configure** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Set up Webhook URL** + +LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: + +```bash +# Example with ngrok +ngrok http 18791 +``` + +Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. + +**4. Run** + +```bash +picoclaw gateway +``` + +> In group chats, the bot responds only when @mentioned. Replies quote the original message. + +> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port. + +
+ +## ClawdChat Join the Agent Social Network + +Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App. + +**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)** + ## ⚙️ Configuration Config file: `~/.picoclaw/config.json` @@ -349,36 +482,219 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ~/.picoclaw/workspace/ ├── sessions/ # Conversation sessions and history ├── memory/ # Long-term memory (MEMORY.md) +├── state/ # Persistent state (last channel, etc.) ├── cron/ # Scheduled jobs database ├── skills/ # Custom skills ├── AGENTS.md # Agent behavior guide +├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) ├── IDENTITY.md # Agent identity ├── SOUL.md # Agent soul ├── TOOLS.md # Tool descriptions └── USER.md # User preferences ``` +### 🔒 Security Sandbox + +PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. + +#### Default Configuration + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | +| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | + +#### Protected Tools + +When `restrict_to_workspace: true`, the following tools are sandboxed: + +| Tool | Function | Restriction | +|------|----------|-------------| +| `read_file` | Read files | Only files within workspace | +| `write_file` | Write files | Only files within workspace | +| `list_dir` | List directories | Only directories within workspace | +| `edit_file` | Edit files | Only files within workspace | +| `append_file` | Append to files | Only files within workspace | +| `exec` | Execute commands | Command paths must be within workspace | + +#### Additional Exec Protection + +Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: + +* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion +* `format`, `mkfs`, `diskpart` — Disk formatting +* `dd if=` — Disk imaging +* Writing to `/dev/sd[a-z]` — Direct disk writes +* `shutdown`, `reboot`, `poweroff` — System shutdown +* Fork bomb `:(){ :|:& };:` + +#### Error Examples + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Disabling Restrictions (Security Risk) + +If you need the agent to access paths outside the workspace: + +**Method 1: Config file** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Method 2: Environment variable** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. + +#### Security Boundary Consistency + +The `restrict_to_workspace` setting applies consistently across all execution paths: + +| Execution Path | Security Boundary | +|----------------|-------------------| +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | + +All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. + +### Heartbeat (Periodic Tasks) + +PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast +``` + +The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools. + +#### Async Tasks with Spawn + +For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) +- Report current time + +## Long Tasks (use spawn for async) +- Search the web for AI news and summarize +- Check email and report important messages +``` + +**Key behaviors:** + +| Feature | Description | +|---------|-------------| +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | + +#### How Subagent Communication Works + +``` +Heartbeat triggers + ↓ +Agent reads HEARTBEAT.md + ↓ +For long task: spawn subagent + ↓ ↓ +Continue to next task Subagent works independently + ↓ ↓ +All tasks done Subagent uses "message" tool + ↓ ↓ +Respond HEARTBEAT_OK User receives result directly +``` + +The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent. + +**Configuration:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | + +**Environment variables:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable +* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval + ### Providers > [!NOTE] > Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. -| Provider | Purpose | Get API Key | -|----------|---------|-------------| -| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | -| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | -| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | -| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | -| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | -| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| Provider | Purpose | Get API Key | +| -------------------------- | --------------------------------------- | ------------------------------------------------------ | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | +| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | + +### Provider Architecture + +PicoClaw routes providers by protocol family: + +- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Anthropic protocol: Claude-native API behavior. +- Codex/OAuth path: OpenAI OAuth/token authentication route. + +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
Zhipu **1. Get API key and base URL** -- Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) +* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. Configure** @@ -397,8 +713,8 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa "zhipu": { "api_key": "Your API Key", "api_base": "https://open.bigmodel.cn/api/paas/v4" - }, - }, + } + } } ``` @@ -459,10 +775,23 @@ picoclaw agent -m "Hello" }, "tools": { "web": { - "search": { - "api_key": "BSA..." + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } + }, + "heartbeat": { + "enabled": true, + "interval": 30 } } ``` @@ -471,23 +800,23 @@ picoclaw agent -m "Hello" ## CLI Reference -| Command | Description | -|---------|-------------| -| `picoclaw onboard` | Initialize config & workspace | -| `picoclaw agent -m "..."` | Chat with the agent | -| `picoclaw agent` | Interactive chat mode | -| `picoclaw gateway` | Start the gateway | -| `picoclaw status` | Show status | -| `picoclaw cron list` | List all scheduled jobs | -| `picoclaw cron add ...` | Add a scheduled job | +| Command | Description | +| ------------------------- | ----------------------------- | +| `picoclaw onboard` | Initialize config & workspace | +| `picoclaw agent -m "..."` | Chat with the agent | +| `picoclaw agent` | Interactive chat mode | +| `picoclaw gateway` | Start the gateway | +| `picoclaw status` | Show status | +| `picoclaw cron list` | List all scheduled jobs | +| `picoclaw cron add ...` | Add a scheduled job | ### Scheduled Tasks / Reminders PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool: -- **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min -- **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours -- **Cron expressions**: "Remind me at 9am daily" → uses cron expression +* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min +* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours +* **Cron expressions**: "Remind me at 9am daily" → uses cron expression Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. @@ -495,6 +824,12 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. PRs welcome! The codebase is intentionally small and readable. 🤗 +Roadmap coming soon... + +Developer group building, Entry Requirement: At least 1 Merged PR. + +User Groups: + discord: PicoClaw @@ -507,21 +842,28 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro To enable web search: -1. Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) -2. Add to `~/.picoclaw/config.json`: +1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results. +2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required). - ```json - { - "tools": { - "web": { - "search": { - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - } - } - } - } - ``` +Add the key to `~/.picoclaw/config.json` if using Brave: + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` ### Getting content filtering errors @@ -535,9 +877,9 @@ This happens when another instance of the bot is running. Make sure only one `pi ## 📝 API Key Comparison -| Service | Free Tier | Use Case | -|---------|-----------|-----------| -| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/month | Best for Chinese users | -| **Brave Search** | 2000 queries/month | Web search functionality | -| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | +| Service | Free Tier | Use Case | +| ---------------- | ------------------- | ------------------------------------- | +| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/month | Best for Chinese users | +| **Brave Search** | 2000 queries/month | Web search functionality | +| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | diff --git a/README.pt-br.md b/README.pt-br.md new file mode 100644 index 000000000..fa73465dd --- /dev/null +++ b/README.pt-br.md @@ -0,0 +1,882 @@ +
+PicoClaw + +

PicoClaw: Assistente de IA Ultra-Eficiente em Go

+ +

Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português** +
+ +--- + +🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código. + +⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS** +> +> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**. +> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**. +> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros, não são nossos. +> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0. +> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10-20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável. + + +## 📢 Novidades + +2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo! + +2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. + +🚀 **Chamada para Ação:** Envie suas solicitações de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na próxima reunião semanal. + +2026-02-09 🎉 PicoClaw lançado oficialmente! Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! + +## ✨ Funcionalidades + +🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. + +💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. + +⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. + +🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era! + +🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop. + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Linguagem** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **Inicialização**
(CPU 0.8GHz) | >500s | >30s | **<1s** | +| **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | + +PicoClaw + +## 🦾 Demonstração + +### 🛠️ Fluxos de Trabalho Padrão do Assistente + + + + + + + + + + + + + + + + + +

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

Desenvolver • Implantar • EscalarAgendar • Automatizar • MemorizarDescobrir • Analisar • Tendências
+ +### 📱 Rode em celulares Android antigos + +Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido: + +1. **Instale o Termux** (Disponível no F-Droid ou Google Play). +2. **Execute os comandos** + +```bash +# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases +wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 +chmod +x picoclaw-linux-arm64 +pkg install proot +termux-chroot ./picoclaw-linux-arm64 onboard +``` + +Depois siga as instruções na seção "Início Rápido" para completar a configuração! + +PicoClaw + +### 🐜 Implantação Inovadora com Baixo Consumo + +O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E (Ethernet) ou W (WiFi6), para Assistente Doméstico Minimalista +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente + +https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 + +🌟 Mais cenários de implantação aguardam você! + +## 📦 Instalação + +### Instalar com binário pré-compilado + +Baixe o binário para sua plataforma na página de [releases](https://github.com/sipeed/picoclaw/releases). + +### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build, sem necessidade de instalar +make build + +# Build para multiplas plataformas +make build-all + +# Build e Instalar +make install +``` + +## 🐳 Docker Compose + +Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente. + +```bash +# 1. Clone este repositorio +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Configure suas API keys +cp config/config.example.json config/config.json +vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. + +# 3. Build & Iniciar +docker compose --profile gateway up -d + +# 4. Ver logs +docker compose logs -f picoclaw-gateway + +# 5. Parar +docker compose --profile gateway down +``` + +### Modo Agente (Execução única) + +```bash +# Fazer uma pergunta +docker compose run --rm picoclaw-agent -m "Quanto e 2+2?" + +# Modo interativo +docker compose run --rm picoclaw-agent +``` + +### Rebuild + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 Início Rápido + +> [!TIP] +> Configure sua API key em `~/.picoclaw/config.json`. +> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado. + +**1. Inicializar** + +```bash +picoclaw onboard +``` + +**2. Configurar** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +**3. Obter API Keys** + +* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponível (2000 consultas/mês) + +> **Nota**: Veja `config.example.json` para um modelo de configuração completo. + +**4. Conversar** + +```bash +picoclaw agent -m "Quanto e 2+2?" +``` + +Pronto! Você tem um assistente de IA funcionando em 2 minutos. + +--- + +## 💬 Integração com Apps de Chat + +Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE. + +| Canal | Nível de Configuração | +| --- | --- | +| **Telegram** | Fácil (apenas um token) | +| **Discord** | Fácil (bot token + intents) | +| **QQ** | Fácil (AppID + AppSecret) | +| **DingTalk** | Médio (credenciais do app) | +| **LINE** | Médio (credenciais + webhook URL) | + +
+Telegram (Recomendado) + +**1. Criar o bot** + +* Abra o Telegram, busque `@BotFather` +* Envie `/newbot`, siga as instruções +* Copie o token + +**2. Configurar** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenha seu User ID pelo `@userinfobot` no Telegram. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+Discord + +**1. Criar o bot** + +* Acesse +* Crie um aplicativo → Bot → Add Bot +* Copie o token do bot + +**2. Habilitar Intents** + +* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT** +* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissões baseada em dados dos membros + +**3. Obter seu User ID** + +* Configurações do Discord → Avançado → habilite **Modo Desenvolvedor** +* Clique com botão direito no seu avatar → **Copiar ID do Usuário** + +**4. Configurar** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Convidar o bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Abra a URL de convite gerada e adicione o bot ao seu servidor + +**6. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Criar o bot** + +- Acesse a [QQ Open Platform](https://q.qq.com/#) +- Crie um aplicativo → Obtenha **AppID** e **AppSecret** + +**2. Configurar** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Criar o bot** + +* Acesse a [Open Platform](https://open.dingtalk.com/) +* Crie um app interno +* Copie o Client ID e Client Secret + +**2. Configurar** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique IDs para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. Criar uma Conta Oficial LINE** + +- Acesse o [LINE Developers Console](https://developers.line.biz/) +- Crie um provider → Crie um canal Messaging API +- Copie o **Channel Secret** e o **Channel Access Token** + +**2. Configurar** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Configurar URL do Webhook** + +O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel: + +```bash +# Exemplo com ngrok +ngrok http 18791 +``` + +Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**. + +**4. Executar** + +```bash +picoclaw gateway +``` + +> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. + +> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook. + +
+ +## ClawdChat Junte-se a Rede Social de Agentes + +Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado. + +**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)** + +## ⚙️ Configuração Detalhada + +Arquivo de configuração: `~/.picoclaw/config.json` + +### Estrutura do Workspace + +O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessoes de conversa e historico +├── memory/ # Memoria de longo prazo (MEMORY.md) +├── state/ # Estado persistente (ultimo canal, etc.) +├── cron/ # Banco de dados de tarefas agendadas +├── skills/ # Skills personalizadas +├── AGENTS.md # Guia de comportamento do Agente +├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) +├── IDENTITY.md # Identidade do Agente +├── SOUL.md # Alma do Agente +├── TOOLS.md # Descrição das ferramentas +└── USER.md # Preferencias do usuario +``` + +### 🔒 Sandbox de Segurança + +O PicoClaw roda em um ambiente sandbox por padrão. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado. + +#### Configuração Padrão + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Opção | Padrão | Descrição | +|-------|--------|-----------| +| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente | +| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace | + +#### Ferramentas Protegidas + +Quando `restrict_to_workspace: true`, as seguintes ferramentas são restritas ao sandbox: + +| Ferramenta | Função | Restrição | +|------------|--------|-----------| +| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | +| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace | +| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace | +| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | +| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace | +| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace | + +#### Proteção Adicional do Exec + +Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: + +* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa +* `format`, `mkfs`, `diskpart` — Formatação de disco +* `dd if=` — Criação de imagem de disco +* Escrita em `/dev/sd[a-z]` — Escrita direta no disco +* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema +* Fork bomb `:(){ :|:& };:` + +#### Exemplos de Erro + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Desabilitar Restrições (Risco de Segurança) + +Se você precisa que o agente acesse caminhos fora do workspace: + +**Método 1: Arquivo de configuração** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Método 2: Variável de ambiente** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados. + +#### Consistência do Limite de Segurança + +A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução: + +| Caminho de Execução | Limite de Segurança | +|----------------------|---------------------| +| Agente Principal | `restrict_to_workspace` ✅ | +| Subagente / Spawn | Herda a mesma restrição ✅ | +| Tarefas Heartbeat | Herda a mesma restrição ✅ | + +Todos os caminhos compartilham a mesma restrição de workspace — nao há como contornar o limite de segurança por meio de subagentes ou tarefas agendadas. + +### Heartbeat (Tarefas Periódicas) + +O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: + +```markdown +# Tarefas Periodicas + +- Verificar meu email para mensagens importantes +- Revisar minha agenda para proximos eventos +- Verificar a previsao do tempo +``` + +O agente lerá este arquivo a cada 30 minutos (configurável) e executará as tarefas usando as ferramentas disponíveis. + +#### Tarefas Assincronas com Spawn + +Para tarefas de longa duração (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: + +```markdown +# Tarefas Periódicas + +## Tarefas Rápidas (resposta direta) +- Informar hora atual + +## Tarefas Longas (usar spawn para async) +- Buscar notícias de IA na web e resumir +- Verificar email e reportar mensagens importantes +``` + +**Comportamentos principais:** + +| Funcionalidade | Descrição | +|----------------|-----------| +| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat | +| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão | +| **Ferramenta message** | Subagente se comunica diretamente com o usuário via ferramenta message | +| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa | + +#### Como Funciona a Comunicação do Subagente + +``` +Heartbeat dispara + ↓ +Agente lê HEARTBEAT.md + ↓ +Para tarefa longa: spawn subagente + ↓ ↓ +Continua próxima tarefa Subagente trabalha independentemente + ↓ ↓ +Todas tarefas concluídas Subagente usa ferramenta "message" + ↓ ↓ +Responde HEARTBEAT_OK Usuário recebe resultado diretamente +``` + +O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal. + +**Configuração:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Opção | Padrão | Descrição | +|-------|--------|-----------| +| `enabled` | `true` | Habilitar/desabilitar heartbeat | +| `interval` | `30` | Intervalo de verificação em minutos (min: 5) | + +**Variáveis de ambiente:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar +* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo + +### Provedores + +> [!NOTE] +> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas. + +| Provedor | Finalidade | Obter API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) | +| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+Configuração Zhipu + +**1. Obter API key** + +* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurar** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Sua API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw agent -m "Ola, como vai?" +``` + +
+ +
+Exemplo de configuraçao completa + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## Referência CLI + +| Comando | Descrição | +| --- | --- | +| `picoclaw onboard` | Inicializar configuração & workspace | +| `picoclaw agent -m "..."` | Conversar com o agente | +| `picoclaw agent` | Modo de chat interativo | +| `picoclaw gateway` | Iniciar o gateway (para bots de chat) | +| `picoclaw status` | Mostrar status | +| `picoclaw cron list` | Listar todas as tarefas agendadas | +| `picoclaw cron add ...` | Adicionar uma tarefa agendada | + +### Tarefas Agendadas / Lembretes + +O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`: + +* **Lembretes únicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez após 10min +* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas +* **Expressões Cron**: "Remind me at 9am daily" (Me lembre às 9h todos os dias) → usa expressão cron + +As tarefas são armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente. + +## 🤝 Contribuir & Roadmap + +PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗 + +Roadmap em breve... + +Grupo de desenvolvedores em formação. Requisito de entrada: Pelo menos 1 PR com merge. + +Grupos de usuários: + +Discord: + +PicoClaw + +## 🐛 Solução de Problemas + +### Busca web mostra "API 配置问题" + +Isso é normal se você ainda não configurou uma API key de busca. O PicoClaw fornecerá links úteis para busca manual. + +Para habilitar a busca web: + +1. **Opção 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas grátis/mês) para os melhores resultados. +2. **Opção 2 (Sem Cartão de Crédito)**: Se você não tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key). + +Adicione a key em `~/.picoclaw/config.json` se usar o Brave: + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +### Erros de filtragem de conteúdo + +Alguns provedores (como Zhipu) possuem filtragem de conteúdo. Tente reformular sua pergunta ou use um modelo diferente. + +### Bot do Telegram diz "Conflict: terminated by other getUpdates" + +Isso acontece quando outra instância do bot está em execução. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez. + +--- + +## 📝 Comparação de API Keys + +| Serviço | Plano Gratuito | Caso de Uso | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses | +| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web | +| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) | diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 000000000..e629eaa9b --- /dev/null +++ b/README.vi.md @@ -0,0 +1,859 @@ +
+PicoClaw + +

PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go

+ +

Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ +**Tiếng Việt** | [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) +
+ +--- + +🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn. + +⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC** +> +> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**. +> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**. +> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi. +> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0. +> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định. + + +## 📢 Tin tức + +2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn! + +2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw. +🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần. + +2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường! + +## ✨ Tính năng nổi bật + +🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi). + +💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini. + +⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz. + +🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy! + +🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người. + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Ngôn ngữ** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **Thời gian khởi động**
(CPU 0.8GHz) | >500s | >30s | **<1s** | +| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**
**Chỉ từ $10** | + +PicoClaw + +## 🦾 Demo + +### 🛠️ Quy trình trợ lý tiêu chuẩn + + + + + + + + + + + + + + + + + +

🧩 Lập trình Full-Stack

🗂️ Quản lý Nhật ký & Kế hoạch

🔎 Tìm kiếm Web & Học hỏi

Phát triển • Triển khai • Mở rộngLên lịch • Tự động hóa • Ghi nhớKhám phá • Phân tích • Xu hướng
+ +### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu + +PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux! + +* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E (Ethernet) hoặc W (WiFi6), dùng làm Trợ lý Gia đình tối giản. +* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), dùng cho quản trị Server tự động. +* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), dùng cho Giám sát thông minh. + +https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 + +🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá! + +## 📦 Cài đặt + +### Cài đặt bằng binary biên dịch sẵn + +Tải file binary cho nền tảng của bạn từ [trang Release](https://github.com/sipeed/picoclaw/releases). + +### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build (không cần cài đặt) +make build + +# Build cho nhiều nền tảng +make build-all + +# Build và cài đặt +make install +``` + +## 🐳 Docker Compose + +Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy. + +```bash +# 1. Clone repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Thiết lập API Key +cp config/config.example.json config/config.json +vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. + +# 3. Build & Khởi động +docker compose --profile gateway up -d + +# 4. Xem logs +docker compose logs -f picoclaw-gateway + +# 5. Dừng +docker compose --profile gateway down +``` + +### Chế độ Agent (chạy một lần) + +```bash +# Đặt câu hỏi +docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?" + +# Chế độ tương tác +docker compose run --rm picoclaw-agent +``` + +### Build lại + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 Bắt đầu nhanh + +> [!TIP] +> Thiết lập API key trong `~/.picoclaw/config.json`. +> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn. + +**1. Khởi tạo** + +```bash +picoclaw onboard +``` + +**2. Cấu hình** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +**3. Lấy API Key** + +* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Tìm kiếm Web** (tùy chọn): [Brave Search](https://brave.com/search/api) — Có gói miễn phí (2000 truy vấn/tháng) + +> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ. + +**4. Trò chuyện** + +```bash +picoclaw agent -m "Xin chào, bạn là ai?" +``` + +Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 phút. + +--- + +## 💬 Tích hợp ứng dụng Chat + +Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE. + +| Kênh | Mức độ thiết lập | +| --- | --- | +| **Telegram** | Dễ (chỉ cần token) | +| **Discord** | Dễ (bot token + intents) | +| **QQ** | Dễ (AppID + AppSecret) | +| **DingTalk** | Trung bình (app credentials) | +| **LINE** | Trung bình (credentials + webhook URL) | + +
+Telegram (Khuyên dùng) + +**1. Tạo bot** + +* Mở Telegram, tìm `@BotFather` +* Gửi `/newbot`, làm theo hướng dẫn +* Sao chép token + +**2. Cấu hình** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +> Lấy User ID từ `@userinfobot` trên Telegram. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+Discord + +**1. Tạo bot** + +* Truy cập +* Create an application → Bot → Add Bot +* Sao chép bot token + +**2. Bật Intents** + +* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT** +* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên + +**3. Lấy User ID** + +* Discord Settings → Advanced → bật **Developer Mode** +* Click chuột phải vào avatar → **Copy User ID** + +**4. Cấu hình** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Mời bot vào server** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Mở URL mời được tạo và thêm bot vào server của bạn + +**6. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Tạo bot** + +* Truy cập [QQ Open Platform](https://q.qq.com/#) +* Tạo ứng dụng → Lấy **AppID** và **AppSecret** + +**2. Cấu hình** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Tạo bot** + +* Truy cập [Open Platform](https://open.dingtalk.com/) +* Tạo ứng dụng nội bộ +* Sao chép Client ID và Client Secret + +**2. Cấu hình** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. Tạo tài khoản LINE Official** + +- Truy cập [LINE Developers Console](https://developers.line.biz/) +- Tạo provider → Tạo Messaging API channel +- Sao chép **Channel Secret** và **Channel Access Token** + +**2. Cấu hình** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Thiết lập Webhook URL** + +LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel: + +```bash +# Ví dụ với ngrok +ngrok http 18791 +``` + +Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. + +**4. Chạy** + +```bash +picoclaw gateway +``` + +> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc. + +> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook. + +
+ +## ClawdChat Tham gia Mạng xã hội Agent + +Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp. + +**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)** + +## ⚙️ Cấu hình chi tiết + +File cấu hình: `~/.picoclaw/config.json` + +### Cấu trúc Workspace + +PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Phiên hội thoại và lịch sử +├── memory/ # Bộ nhớ dài hạn (MEMORY.md) +├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.) +├── cron/ # Cơ sở dữ liệu tác vụ định kỳ +├── skills/ # Kỹ năng tùy chỉnh +├── AGENTS.md # Hướng dẫn hành vi Agent +├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) +├── IDENTITY.md # Danh tính Agent +├── SOUL.md # Tâm hồn/Tính cách Agent +├── TOOLS.md # Mô tả công cụ +└── USER.md # Tùy chọn người dùng +``` + +### 🔒 Hộp cát bảo mật (Security Sandbox) + +PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace. + +#### Cấu hình mặc định + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Tùy chọn | Mặc định | Mô tả | +|----------|---------|-------| +| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent | +| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace | + +#### Công cụ được bảo vệ + +Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox: + +| Công cụ | Chức năng | Giới hạn | +|---------|----------|---------| +| `read_file` | Đọc file | Chỉ file trong workspace | +| `write_file` | Ghi file | Chỉ file trong workspace | +| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace | +| `edit_file` | Sửa file | Chỉ file trong workspace | +| `append_file` | Thêm vào file | Chỉ file trong workspace | +| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace | + +#### Bảo vệ bổ sung cho Exec + +Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau: + +* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt +* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa +* `dd if=` — Tạo ảnh đĩa +* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa +* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống +* Fork bomb `:(){ :|:& };:` + +#### Ví dụ lỗi + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Tắt giới hạn (Rủi ro bảo mật) + +Nếu bạn cần agent truy cập đường dẫn ngoài workspace: + +**Cách 1: File cấu hình** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Cách 2: Biến môi trường** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát. + +#### Tính nhất quán của ranh giới bảo mật + +Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi: + +| Đường thực thi | Ranh giới bảo mật | +|----------------|-------------------| +| Agent chính | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Kế thừa cùng giới hạn ✅ | +| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ | + +Tất cả đường thực thi chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật thông qua subagent hoặc tác vụ định kỳ. + +### Heartbeat (Tác vụ định kỳ) + +PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace: + +```markdown +# Tác vụ định kỳ + +- Kiểm tra email xem có tin nhắn quan trọng không +- Xem lại lịch cho các sự kiện sắp tới +- Kiểm tra dự báo thời tiết +``` + +Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn. + +#### Tác vụ bất đồng bộ với Spawn + +Đối với các tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**: + +```markdown +# Tác vụ định kỳ + +## Tác vụ nhanh (trả lời trực tiếp) +- Báo cáo thời gian hiện tại + +## Tác vụ lâu (dùng spawn cho async) +- Tìm kiếm tin tức AI trên web và tóm tắt +- Kiểm tra email và báo cáo tin nhắn quan trọng +``` + +**Hành vi chính:** + +| Tính năng | Mô tả | +|-----------|-------| +| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat | +| **Context độc lập** | Subagent có context riêng, không có lịch sử phiên | +| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua công cụ message | +| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo | + +#### Cách Subagent giao tiếp + +``` +Heartbeat kích hoạt + ↓ +Agent đọc HEARTBEAT.md + ↓ +Tác vụ lâu: spawn subagent + ↓ ↓ +Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập + ↓ ↓ +Tất cả tác vụ hoàn thành Subagent dùng công cụ "message" + ↓ ↓ +Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp +``` + +Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính. + +**Cấu hình:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Tùy chọn | Mặc định | Mô tả | +|----------|---------|-------| +| `enabled` | `true` | Bật/tắt heartbeat | +| `interval` | `30` | Khoảng thời gian kiểm tra (phút, tối thiểu: 5) | + +**Biến môi trường:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt +* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian + +### Nhà cung cấp (Providers) + +> [!NOTE] +> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản. + +| Nhà cung cấp | Mục đích | Lấy API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) | +| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+Cấu hình Zhipu + +**1. Lấy API key** + +* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Cấu hình** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw agent -m "Xin chào" +``` + +
+ +
+Ví dụ cấu hình đầy đủ + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## Tham chiếu CLI + +| Lệnh | Mô tả | +| --- | --- | +| `picoclaw onboard` | Khởi tạo cấu hình & workspace | +| `picoclaw agent -m "..."` | Trò chuyện với agent | +| `picoclaw agent` | Chế độ chat tương tác | +| `picoclaw gateway` | Khởi động gateway (cho bot chat) | +| `picoclaw status` | Hiển thị trạng thái | +| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ | +| `picoclaw cron add ...` | Thêm tác vụ định kỳ | + +### Tác vụ định kỳ / Nhắc nhở + +PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`: + +* **Nhắc nhở một lần**: "Remind me in 10 minutes" (Nhắc tôi sau 10 phút) → kích hoạt một lần sau 10 phút +* **Tác vụ lặp lại**: "Remind me every 2 hours" (Nhắc tôi mỗi 2 giờ) → kích hoạt mỗi 2 giờ +* **Biểu thức Cron**: "Remind me at 9am daily" (Nhắc tôi lúc 9 giờ sáng mỗi ngày) → sử dụng biểu thức cron + +Các tác vụ được lưu trong `~/.picoclaw/workspace/cron/` và được xử lý tự động. + +## 🤝 Đóng góp & Lộ trình + +Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗 + +Lộ trình sắp được công bố... + +Nhóm phát triển đang được xây dựng. Điều kiện tham gia: Ít nhất 1 PR đã được merge. + +Nhóm người dùng: + +Discord: + +PicoClaw + +## 🐛 Xử lý sự cố + +### Tìm kiếm web hiện "API 配置问题" + +Điều này là bình thường nếu bạn chưa cấu hình API key cho tìm kiếm. PicoClaw sẽ cung cấp các liên kết hữu ích để tìm kiếm thủ công. + +Để bật tìm kiếm web: + +1. **Tùy chọn 1 (Khuyên dùng)**: Lấy API key miễn phí tại [https://brave.com/search/api](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng) để có kết quả tốt nhất. +2. **Tùy chọn 2 (Không cần thẻ tín dụng)**: Nếu không có key, hệ thống tự động chuyển sang dùng **DuckDuckGo** (không cần key). + +Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave: + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +### Gặp lỗi lọc nội dung (Content Filtering) + +Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác. + +### Telegram bot báo "Conflict: terminated by other getUpdates" + +Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm. + +--- + +## 📝 So sánh API Key + +| Dịch vụ | Gói miễn phí | Trường hợp sử dụng | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) | +| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc | +| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web | +| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) | diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 000000000..42bd20be4 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,744 @@ +
+PicoClaw + +

PicoClaw: 基于Go语言的超高效 AI 助手

+ +

10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) +
+ +--- + +🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 + +⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%! + + + + + + +
+

+ +

+
+

+ +

+
+ +注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。 + +> [!CAUTION] +> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** +> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 +> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 +> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 +> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中 +> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化. + + +## 📢 新闻 (News) +2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与! + +2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。 +🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。 + +2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走! + +## ✨ 特性 + +🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。 + +💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。 + +⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 + +🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行! + +🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 + +| | OpenClaw | NanoBot | **PicoClaw** | +| --- | --- | --- | --- | +| **语言** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | +| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | + +PicoClaw + +## 🦾 演示 + +### 🛠️ 标准助手工作流 + + + + + + + + + + + + + + + + + +

🧩 全栈工程师模式

🗂️ 日志与规划管理

🔎 网络搜索与学习

开发 • 部署 • 扩展日程 • 自动化 • 记忆发现 • 洞察 • 趋势
+ +### 📱 在手机上轻松运行 +picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南: +1. 先去应用商店下载安装Termux +2. 打开后执行指令 +```bash +# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本 +wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 +chmod +x picoclaw-linux-arm64 +pkg install proot +termux-chroot ./picoclaw-linux-arm64 onboard +``` +然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! +PicoClaw + + + + +### 🐜 创新的低占用部署 + +PicoClaw 几乎可以部署在任何 Linux 设备上! + +* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。 +* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。 +* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。 + +[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4) + +🌟 更多部署案例敬请期待! + +## 📦 安装 + +### 使用预编译二进制文件安装 + +从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。 + +### 从源码安装(获取最新特性,开发推荐) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# 构建(无需安装) +make build + +# 为多平台构建 +make build-all + +# 构建并安装 +make install + +``` + +## 🐳 Docker Compose + +您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 + +```bash +# 1. 克隆仓库 +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. 设置 API Key +cp config/config.example.json config/config.json +vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等 + +# 3. 构建并启动 +docker compose --profile gateway up -d + +# 4. 查看日志 +docker compose logs -f picoclaw-gateway + +# 5. 停止 +docker compose --profile gateway down + +``` + +### Agent 模式 (一次性运行) + +```bash +# 提问 +docker compose run --rm picoclaw-agent -m "2+2 等于几?" + +# 交互模式 +docker compose run --rm picoclaw-agent + +``` + +### 重新构建 + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d + +``` + +### 🚀 快速开始 + +> [!TIP] +> 在 `~/.picoclaw/config.json` 中设置您的 API Key。 +> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询) + +**1. 初始化 (Initialize)** + +```bash +picoclaw onboard + +``` + +**2. 配置 (Configure)** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + } +} + +``` + +**3. 获取 API Key** + +* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月) + +> **注意**: 完整的配置模板请参考 `config.example.json`。 + +**4. 对话 (Chat)** + +```bash +picoclaw agent -m "2+2 等于几?" + +``` + +就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 + +--- + +## 💬 聊天应用集成 (Chat Apps) + +通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。 + +| 渠道 | 设置难度 | +| --- | --- | +| **Telegram** | 简单 (仅需 token) | +| **Discord** | 简单 (bot token + intents) | +| **QQ** | 简单 (AppID + AppSecret) | +| **钉钉 (DingTalk)** | 中等 (app credentials) | + +
+Telegram (推荐) + +**1. 创建机器人** + +* 打开 Telegram,搜索 `@BotFather` +* 发送 `/newbot`,按照提示操作 +* 复制 token + +**2. 配置** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} + +``` + +> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。 + +**3. 运行** + +```bash +picoclaw gateway + +``` + +
+ +
+Discord + +**1. 创建机器人** + +* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications) +* Create an application → Bot → Add Bot +* 复制 bot token + +**2. 开启 Intents** + +* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT** +* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT** + +**3. 获取您的 User ID** + +* Discord 设置 → Advanced → 开启 **Developer Mode** +* 右键点击您的头像 → **Copy User ID** + +**4. 配置** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"] + } + } +} + +``` + +**5. 邀请机器人** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* 打开生成的邀请 URL,将机器人添加到您的服务器 + +**6. 运行** + +```bash +picoclaw gateway + +``` + +
+ +
+QQ + +**1. 创建机器人** + +* 前往 [QQ 开放平台](https://q.qq.com/#) +* 创建应用 → 获取 **AppID** 和 **AppSecret** + +**2. 配置** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} + +``` + +> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。 + +**3. 运行** + +```bash +picoclaw gateway + +``` + +
+ +
+钉钉 (DingTalk) + +**1. 创建机器人** + +* 前往 [开放平台](https://open.dingtalk.com/) +* 创建内部应用 +* 复制 Client ID 和 Client Secret + +**2. 配置** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} + +``` + +> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。 + +**3. 运行** + +```bash +picoclaw gateway + +``` + +
+ +## ClawdChat 加入 Agent 社交网络 + +只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 + +**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai) + +## ⚙️ 配置详解 + +配置文件路径: `~/.picoclaw/config.json` + +### 工作区布局 (Workspace Layout) + +PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # 对话会话和历史 +├── memory/ # 长期记忆 (MEMORY.md) +├── state/ # 持久化状态 (最后一次频道等) +├── cron/ # 定时任务数据库 +├── skills/ # 自定义技能 +├── AGENTS.md # Agent 行为指南 +├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) +├── IDENTITY.md # Agent 身份设定 +├── SOUL.md # Agent 灵魂/性格 +├── TOOLS.md # 工具描述 +└── USER.md # 用户偏好 + +``` + +### 心跳 / 周期性任务 (Heartbeat) + +PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast + +``` + +Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 + +#### 使用 Spawn 的异步任务 + +对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) +- Report current time + +## Long Tasks (use spawn for async) +- Search the web for AI news and summarize +- Check email and report important messages + +``` + +**关键行为:** + +| 特性 | 描述 | +| --- | --- | +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | + +#### 子 Agent 通信原理 + +``` +心跳触发 (Heartbeat triggers) + ↓ +Agent 读取 HEARTBEAT.md + ↓ +对于长任务: spawn 子 Agent + ↓ ↓ +继续下一个任务 子 Agent 独立工作 + ↓ ↓ +所有任务完成 子 Agent 使用 "message" 工具 + ↓ ↓ +响应 HEARTBEAT_OK 用户直接收到结果 + +``` + +子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 + +**配置:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} + +``` + +| 选项 | 默认值 | 描述 | +| --- | --- | --- | +| `enabled` | `true` | 启用/禁用心跳 | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | + +**环境变量:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 + +### 提供商 (Providers) + +> [!NOTE] +> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。 + +| 提供商 | 用途 | 获取 API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | +| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | +| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | +| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+智谱 (Zhipu) 配置示例 + +**1. 获取 API key 和 base URL** + +* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. 配置** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + }, + }, +} + +``` + +**3. 运行** + +```bash +picoclaw agent -m "你好" + +``` + +
+ +
+完整配置示例 + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "search": { + "api_key": "BSA..." + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} + +``` + +
+ +## CLI 命令行参考 + +| 命令 | 描述 | +| --- | --- | +| `picoclaw onboard` | 初始化配置和工作区 | +| `picoclaw agent -m "..."` | 与 Agent 对话 | +| `picoclaw agent` | 交互式聊天模式 | +| `picoclaw gateway` | 启动网关 (Gateway) | +| `picoclaw status` | 显示状态 | +| `picoclaw cron list` | 列出所有定时任务 | +| `picoclaw cron add ...` | 添加定时任务 | + +### 定时任务 / 提醒 (Scheduled Tasks) + +PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: + +* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次 +* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发 +* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式 + +任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。 + +## 🤝 贡献与路线图 (Roadmap) + +欢迎提交 PR!代码库刻意保持小巧和可读。🤗 + +路线图即将发布... + +开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 + +用户群组: + +Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) + +PicoClaw + +## 🐛 疑难解答 (Troubleshooting) + +### 网络搜索提示 "API 配置问题" + +如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。 + +启用网络搜索: + +1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询) +2. 添加到 `~/.picoclaw/config.json`: +```json +{ + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + } + } +} + +``` + + + +### 遇到内容过滤错误 (Content Filtering Errors) + +某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。 + +### Telegram bot 提示 "Conflict: terminated by other getUpdates" + +这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。 + +--- + +## 📝 API Key 对比 + +| 服务 | 免费层级 | 适用场景 | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | +| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | +| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | +| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..8c5c0e252 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,116 @@ + +# 🦐 PicoClaw Roadmap + +> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity + +--- + +## 🚀 1. Core Optimization: Extreme Lightweight + +*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.* + +* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346) + * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB. + * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size. + * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures. + + +## 🛡️ 2. Security Hardening: Defense in Depth + +*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.* + +* **Input Defense & Permission Control** + * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation. + * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries. + * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services). + + +* **Sandboxing & Isolation** + * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only. + * **Context Isolation**: Prevent data leakage between different user sessions or channels. + * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs. + + +* **Authentication & Secrets** + * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage. + * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows. + + + +## 🔌 3. Connectivity: Protocol-First Architecture + +*Connect every model, reach every platform.* + +* **Provider** + * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)* + * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference). + * **Online Models**: Continued support for frontier closed-source models. + + +* **Channel** + * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ... + * **Standards**: Support for the **OneBot** protocol. + * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments. + + +* **Skill Marketplace** + * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries. + + + +## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI + +*Beyond conversation—focusing on action and collaboration.* + +* **Operations** + * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**. + * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook. + * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop). + + +* **Multi-Agent Collaboration** + * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement + * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart). + * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network. + * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms. + + + +## 📚 5. Developer Experience (DevEx) & Documentation + +*Lowering the barrier to entry so anyone can deploy in minutes.* + +* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350) + * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step. + + +* **Comprehensive Documentation** + * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android. + * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels. + * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations). + + + +## 🤖 6. Engineering: AI-Powered Open Source + +*Born from Vibe Coding, we continue to use AI to accelerate development.* + +* **AI-Enhanced CI/CD** + * Integrate AI for automated Code Review, Linting, and PR Labeling. + * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean. + * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes. + + + +## 🎨 7. Brand & Community + +* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design! + * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes." + + + +--- + +### 🤝 Call for Contributions + +We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together! \ No newline at end of file diff --git a/assets/clawdchat-icon.png b/assets/clawdchat-icon.png new file mode 100644 index 000000000..65e377c5e Binary files /dev/null and b/assets/clawdchat-icon.png differ diff --git a/assets/termux.jpg b/assets/termux.jpg new file mode 100644 index 000000000..30c724a20 Binary files /dev/null and b/assets/termux.jpg differ diff --git a/assets/wechat.png b/assets/wechat.png index 4e9d0df41..8fc41ea7d 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 0ea6066f0..128f8c421 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -9,8 +9,11 @@ package main import ( "bufio" "context" + "embed" "fmt" "io" + "io/fs" + "net/http" "os" "os/signal" "path/filepath" @@ -25,32 +28,58 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" + "github.com/sipeed/picoclaw/pkg/devices" + "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/migrate" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" ) +//go:generate cp -r ../../workspace . +//go:embed workspace +var embeddedFiles embed.FS + var ( - version = "0.1.0" + version = "dev" + gitCommit string buildTime string goVersion string ) const logo = "🦞" -func printVersion() { - fmt.Printf("%s picoclaw v%s\n", logo, version) - if buildTime != "" { - fmt.Printf(" Build: %s\n", buildTime) +// formatVersion returns the version string with optional git commit +func formatVersion() string { + v := version + if gitCommit != "" { + v += fmt.Sprintf(" (git: %s)", gitCommit) } - goVer := goVersion + return v +} + +// formatBuildInfo returns build time and go version info +func formatBuildInfo() (build string, goVer string) { + if buildTime != "" { + build = buildTime + } + goVer = goVersion if goVer == "" { goVer = runtime.Version() } + return +} + +func printVersion() { + fmt.Printf("%s picoclaw %s\n", logo, formatVersion()) + build, goVer := formatBuildInfo() + if build != "" { + fmt.Printf(" Build: %s\n", build) + } if goVer != "" { fmt.Printf(" Go: %s\n", goVer) } @@ -208,10 +237,6 @@ func onboard() { } workspace := cfg.WorkspacePath() - os.MkdirAll(workspace, 0755) - os.MkdirAll(filepath.Join(workspace, "memory"), 0755) - os.MkdirAll(filepath.Join(workspace, "skills"), 0755) - createWorkspaceTemplates(workspace) fmt.Printf("%s picoclaw is ready!\n", logo) @@ -221,170 +246,57 @@ func onboard() { fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") } +func copyEmbeddedToTarget(targetDir string) error { + // Ensure target directory exists + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("Failed to create target directory: %w", err) + } + + // Walk through all files in embed.FS + err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() { + return nil + } + + // Read embedded file + data, err := embeddedFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("Failed to read embedded file %s: %w", path, err) + } + + new_path, err := filepath.Rel("workspace", path) + if err != nil { + return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) + } + + // Build target file path + targetPath := filepath.Join(targetDir, new_path) + + // Ensure target file's directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) + } + + // Write file + if err := os.WriteFile(targetPath, data, 0644); err != nil { + return fmt.Errorf("Failed to write file %s: %w", targetPath, err) + } + + return nil + }) + + return err +} + func createWorkspaceTemplates(workspace string) { - templates := map[string]string{ - "AGENTS.md": `# Agent Instructions - -You are a helpful AI assistant. Be concise, accurate, and friendly. - -## Guidelines - -- Always explain what you're doing before taking actions -- Ask for clarification when request is ambiguous -- Use tools to help accomplish tasks -- Remember important information in your memory files -- Be proactive and helpful -- Learn from user feedback -`, - "SOUL.md": `# Soul - -I am picoclaw, a lightweight AI assistant powered by AI. - -## Personality - -- Helpful and friendly -- Concise and to the point -- Curious and eager to learn -- Honest and transparent - -## Values - -- Accuracy over speed -- User privacy and safety -- Transparency in actions -- Continuous improvement -`, - "USER.md": `# User - -Information about user goes here. - -## Preferences - -- Communication style: (casual/formal) -- Timezone: (your timezone) -- Language: (your preferred language) - -## Personal Information - -- Name: (optional) -- Location: (optional) -- Occupation: (optional) - -## Learning Goals - -- What the user wants to learn from AI -- Preferred interaction style -- Areas of interest -`, - "IDENTITY.md": `# Identity - -## Name -PicoClaw 🦞 - -## Description -Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. - -## Version -0.1.0 - -## Purpose -- Provide intelligent AI assistance with minimal resource usage -- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.) -- Enable easy customization through skills system -- Run on minimal hardware ($10 boards, <10MB RAM) - -## Capabilities - -- Web search and content fetching -- File system operations (read, write, edit) -- Shell command execution -- Multi-channel messaging (Telegram, WhatsApp, Feishu) -- Skill-based extensibility -- Memory and context management - -## Philosophy - -- Simplicity over complexity -- Performance over features -- User control and privacy -- Transparent operation -- Community-driven development - -## Goals - -- Provide a fast, lightweight AI assistant -- Support offline-first operation where possible -- Enable easy customization and extension -- Maintain high quality responses -- Run efficiently on constrained hardware - -## License -MIT License - Free and open source - -## Repository -https://github.com/sipeed/picoclaw - -## Contact -Issues: https://github.com/sipeed/picoclaw/issues -Discussions: https://github.com/sipeed/picoclaw/discussions - ---- - -"Every bit helps, every bit matters." -- Picoclaw -`, - } - - for filename, content := range templates { - filePath := filepath.Join(workspace, filename) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - os.WriteFile(filePath, []byte(content), 0644) - fmt.Printf(" Created %s\n", filename) - } - } - - memoryDir := filepath.Join(workspace, "memory") - os.MkdirAll(memoryDir, 0755) - memoryFile := filepath.Join(memoryDir, "MEMORY.md") - if _, err := os.Stat(memoryFile); os.IsNotExist(err) { - memoryContent := `# Long-term Memory - -This file stores important information that should persist across sessions. - -## User Information - -(Important facts about user) - -## Preferences - -(User preferences learned over time) - -## Important Notes - -(Things to remember) - -## Configuration - -- Model preferences -- Channel settings -- Skills enabled -` - os.WriteFile(memoryFile, []byte(memoryContent), 0644) - fmt.Println(" Created memory/MEMORY.md") - - skillsDir := filepath.Join(workspace, "skills") - if _, err := os.Stat(skillsDir); os.IsNotExist(err) { - os.MkdirAll(skillsDir, 0755) - fmt.Println(" Created skills/") - } - } - - for filename, content := range templates { - filePath := filepath.Join(workspace, filename) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - os.WriteFile(filePath, []byte(content), 0644) - fmt.Printf(" Created %s\n", filename) - } + err := copyEmbeddedToTarget(workspace) + if err != nil { + fmt.Printf("Error copying workspace templates: %v\n", err) } } @@ -650,14 +562,32 @@ func gatewayCmd() { }) // Setup cron tool and service - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath()) + execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), - nil, - 30*60, - true, + cfg.Heartbeat.Interval, + cfg.Heartbeat.Enabled, ) + heartbeatService.SetBus(msgBus) + heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + // Use cli:direct as fallback if no valid channel + if channel == "" || chatID == "" { + channel, chatID = "cli", "direct" + } + // Use ProcessHeartbeat - no session history, each heartbeat is independent + response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + if err != nil { + return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) + } + if response == "HEARTBEAT_OK" { + return tools.SilentResult("Heartbeat OK") + } + // For heartbeat, always return silent - the subagent result will be + // sent to user via processSystemMessage when the async task completes + return tools.SilentResult(response) + }) channelManager, err := channels.NewManager(cfg, msgBus) if err != nil { @@ -665,6 +595,9 @@ func gatewayCmd() { os.Exit(1) } + // Inject channel manager into agent loop for command handling + agentLoop.SetChannelManager(channelManager) + var transcriber *voice.GroqTranscriber if cfg.Providers.Groq.APIKey != "" { transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) @@ -715,10 +648,30 @@ func gatewayCmd() { } fmt.Println("✓ Heartbeat service started") + stateManager := state.NewManager(cfg.WorkspacePath()) + deviceService := devices.NewService(devices.Config{ + Enabled: cfg.Devices.Enabled, + MonitorUSB: cfg.Devices.MonitorUSB, + }, stateManager) + deviceService.SetBus(msgBus) + if err := deviceService.Start(ctx); err != nil { + fmt.Printf("Error starting device service: %v\n", err) + } else if cfg.Devices.Enabled { + fmt.Println("✓ Device event service started") + } + if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } + healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + go func() { + if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()}) + } + }() + fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) + go agentLoop.Run(ctx) sigChan := make(chan os.Signal, 1) @@ -727,6 +680,8 @@ func gatewayCmd() { fmt.Println("\nShutting down...") cancel() + healthServer.Stop(context.Background()) + deviceService.Stop() heartbeatService.Stop() cronService.Stop() agentLoop.Stop() @@ -743,7 +698,13 @@ func statusCmd() { configPath := getConfigPath() - fmt.Printf("%s picoclaw Status\n\n", logo) + fmt.Printf("%s picoclaw Status\n", logo) + fmt.Printf("Version: %s\n", formatVersion()) + build, _ := formatBuildInfo() + if build != "" { + fmt.Printf("Build: %s\n", build) + } + fmt.Println() if _, err := os.Stat(configPath); err == nil { fmt.Println("Config:", configPath, "✓") @@ -1027,14 +988,14 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService { +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, config) agentLoop.RegisterTool(cronTool) // Set the onJob handler @@ -1264,53 +1225,6 @@ func cronEnableCmd(storePath string, disable bool) { } } -func skillsCmd() { - if len(os.Args) < 3 { - skillsHelp() - return - } - - subcommand := os.Args[2] - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - workspace := cfg.WorkspacePath() - installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 - globalDir := filepath.Dir(getConfigPath()) - globalSkillsDir := filepath.Join(globalDir, "skills") - builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") - skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) - - switch subcommand { - case "list": - skillsListCmd(skillsLoader) - case "install": - skillsInstallCmd(installer) - case "remove", "uninstall": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills remove ") - return - } - skillsRemoveCmd(installer, os.Args[3]) - case "search": - skillsSearchCmd(installer) - case "show": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills show ") - return - } - skillsShowCmd(skillsLoader, os.Args[3]) - default: - fmt.Printf("Unknown skills command: %s\n", subcommand) - skillsHelp() - } -} - func skillsHelp() { fmt.Println("\nSkills commands:") fmt.Println(" list List installed skills") diff --git a/config.example.json b/config/config.example.json similarity index 59% rename from config.example.json rename to config/config.example.json index 938230f0b..ec7f07b95 100644 --- a/config.example.json +++ b/config/config.example.json @@ -2,6 +2,7 @@ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true, "model": "openrouter/auto", "max_tokens": 8192, "temperature": 0.7, @@ -12,7 +13,10 @@ "telegram": { "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] + "proxy": "", + "allow_from": [ + "YOUR_USER_ID" + ] }, "discord": { "enabled": false, @@ -49,6 +53,23 @@ "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", "allow_from": [] + }, + "line": { + "enabled": false, + "channel_secret": "YOUR_LINE_CHANNEL_SECRET", + "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + }, + "onebot": { + "enabled": false, + "ws_url": "ws://127.0.0.1:3001", + "access_token": "", + "reconnect_interval": 5, + "group_trigger_prefix": [], + "allow_from": [] } }, "providers": { @@ -58,7 +79,8 @@ }, "openai": { "api_key": "", - "api_base": "" + "api_base": "", + "web_search": true }, "openrouter": { "api_key": "sk-or-v1-xxx", @@ -79,16 +101,46 @@ "vllm": { "api_key": "", "api_base": "" + }, + "nvidia": { + "api_key": "nvapi-xxx", + "api_base": "", + "proxy": "http://127.0.0.1:7890" + }, + "moonshot": { + "api_key": "sk-xxx", + "api_base": "" + }, + "ollama": { + "api_key": "", + "api_base": "http://localhost:11434/v1" } }, "tools": { "web": { - "search": { + "brave": { + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "pplx-xxx", + "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } }, + "heartbeat": { + "enabled": true, + "interval": 30 + }, + "devices": { + "enabled": false, + "monitor_usb": true + }, "gateway": { "host": "0.0.0.0", "port": 18790 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..32e8ee339 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + # ───────────────────────────────────────────── + # PicoClaw Agent (one-shot query) + # docker compose run --rm picoclaw-agent -m "Hello" + # ───────────────────────────────────────────── + picoclaw-agent: + build: + context: . + dockerfile: Dockerfile + container_name: picoclaw-agent + profiles: + - agent + volumes: + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace + entrypoint: ["picoclaw", "agent"] + stdin_open: true + tty: true + + # ───────────────────────────────────────────── + # PicoClaw Gateway (Long-running Bot) + # docker compose up picoclaw-gateway + # ───────────────────────────────────────────── + picoclaw-gateway: + build: + context: . + dockerfile: Dockerfile + container_name: picoclaw-gateway + restart: unless-stopped + profiles: + - gateway + volumes: + # Configuration file + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro + # Persistent workspace (sessions, memory, logs) + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace + command: ["gateway"] + +volumes: + picoclaw-workspace: diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md new file mode 100644 index 000000000..cfcc30f17 --- /dev/null +++ b/docs/picoclaw_community_roadmap_260216.md @@ -0,0 +1,112 @@ +## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal + +**Hello, PicoClaw Community!** + +First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs! + +PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum. + +This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**. + +### 🎁 Community Perks + +To show our appreciation, developers who officially join our community operations will receive: + +* **Exclusive AI Hardware:** Our upcoming, unreleased AI device. +* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers). + +### 🎥 Calling All Content Creators! + +Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**. + +* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**. +* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM. +We will be rewarding high-quality content creators with the same perks as our community developers! + +--- + +## 🛠️ Urgent Volunteer Roles + +We are looking for experts in the following areas: + +1. **Issue/PR Reviewers** +* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging. +* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles. + + +2. **Resource Optimization Experts** +* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean. +* **Focus:** Analyzing resource growth between releases and trimming redundancy. +* **Priority:** **RAM usage optimization** > Binary size reduction. + + +3. **Security Audit & Bug Fixes** +* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management. +* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes. + + +4. **Documentation & DX (Developer Experience)** +* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow. +* **Focus:** Creating clear, user-friendly documentation for both setup and development. + + +5. **AI-Powered CI/CD Optimization** +* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it. +* **Focus:** Automating builds with AI and exploring AI-driven issue resolution. + +**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role. +Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors! + +--- + +## 📍 The Roadmap + +Interested in a specific feature? You can "claim" these tasks and start building: + +### +* **Provider:** + * **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days) + * You can still submit code; Daming will merge it into the new implementation. +* **Channels:** + * Support for OneBot, additional platforms + * attachments (images, audio, video, files). +* **Skills:** + * Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms. +* **Operations:** * MCP Support. + * Android operations (e.g., botdrop). + * Browser automation via CDP or ActionBook. + + +* **Multi-Agent Ecosystem:** + * **Basic Model-Agnet** S + * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens). + * **Swarm Mode.** + * **AIEOS Integration.** + + +* **Branding:** + * **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch! + + +We have officially created these tasks as GitHub Issues, all marked with the roadmap tag. +This list will be updated continuously as we progress. +If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue! + +--- + +## 🤝 How to Join + +**Everything is open to your creativity!** If you have a wild idea, just PR it. + +1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw. +2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject: +> `[Apply Join PicoClaw Dev Group] + Your GitHub Account` +> Include the role you're interested in and any evidence of your development experience. + + + +### Looking Ahead + +Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential. + +**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎 diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md new file mode 100644 index 000000000..8777ddbd6 --- /dev/null +++ b/docs/tools_configuration.md @@ -0,0 +1,122 @@ +# Tools Configuration + +PicoClaw's tools configuration is located in the `tools` field of `config.json`. + +## Directory Structure + +```json +{ + "tools": { + "web": { ... }, + "exec": { ... }, + "approval": { ... }, + "cron": { ... } + } +} +``` + +## Web Tools + +Web tools are used for web search and fetching. + +### Brave + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Brave search | +| `api_key` | string | - | Brave Search API key | +| `max_results` | int | 5 | Maximum number of results | + +### DuckDuckGo + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable DuckDuckGo search | +| `max_results` | int | 5 | Maximum number of results | + +### Perplexity + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Perplexity search | +| `api_key` | string | - | Perplexity API key | +| `max_results` | int | 5 | Maximum number of results | + +## Exec Tool + +The exec tool is used to execute shell commands. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | +| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | + +### Functionality + +- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns +- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked + +### Default Blocked Command Patterns + +By default, PicoClaw blocks the following dangerous commands: + +- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s` +- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*` +- System operations: `shutdown`, `reboot`, `poweroff` +- Command substitution: `$()`, `${}`, backticks +- Pipe to shell: `| sh`, `| bash` +- Privilege escalation: `sudo`, `chmod`, `chown` +- Process control: `pkill`, `killall`, `kill -9` +- Remote operations: `curl | sh`, `wget | sh`, `ssh` +- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Containers: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Other: `eval`, `source *.sh` + +### Configuration Example + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ], + } + } +} +``` + +## Approval Tool + +The approval tool controls permissions for dangerous operations. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable approval functionality | +| `write_file` | bool | true | Require approval for file writes | +| `edit_file` | bool | true | Require approval for file edits | +| `append_file` | bool | true | Require approval for file appends | +| `exec` | bool | true | Require approval for command execution | +| `timeout_minutes` | int | 5 | Approval timeout in minutes | + +## Cron Tool + +The cron tool is used for scheduling periodic tasks. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | + +## Environment Variables + +All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: + +For example: +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` + +Note: Array-type environment variables are not currently supported and must be set via the config file. diff --git a/go.mod b/go.mod index f4c233ea8..1f88639c8 100644 --- a/go.mod +++ b/go.mod @@ -13,20 +13,29 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 - github.com/openai/openai-go/v3 v3.21.0 + github.com/openai/openai-go/v3 v3.22.0 github.com/slack-go/slack v0.17.3 + github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 golang.org/x/oauth2 v0.35.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + require ( github.com/andybalholm/brotli v1.2.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 github.com/cloudwego/base64x v0.1.6 // indirect + github.com/github/copilot-sdk/go v0.1.23 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 9174d2889..0e95bf5cd 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= +github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -56,6 +58,10 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -74,9 +80,11 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= @@ -92,12 +100,13 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= -github.com/openai/openai-go/v3 v3.21.0 h1:3GpIR/W4q/v1uUOVuK3zYtQiF3DnRrZag/sxbtvEdtc= -github.com/openai/openai-go/v3 v3.21.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= +github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= @@ -238,6 +247,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= diff --git a/pkg/agent/context.go b/pkg/agent/context.go index e737fbda4..cf5ce2913 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -170,8 +170,8 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str // Log system prompt summary for debugging (debug mode only) logger.DebugCF("agent", "System prompt built", map[string]interface{}{ - "total_chars": len(systemPrompt), - "total_lines": strings.Count(systemPrompt, "\n") + 1, + "total_chars": len(systemPrompt), + "total_lines": strings.Count(systemPrompt, "\n") + 1, "section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1, }) @@ -189,6 +189,17 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary } + //This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM + // --- INICIO DEL FIX --- + //Diegox-17 + for len(history) > 0 && (history[0].Role == "tool") { + logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error", + map[string]interface{}{"role": history[0].Role}) + history = history[1:] + } + //Diegox-17 + // --- FIN DEL FIX --- + messages = append(messages, providers.Message{ Role: "system", Content: systemPrompt, diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go new file mode 100644 index 000000000..54a5396e7 --- /dev/null +++ b/pkg/agent/instance.go @@ -0,0 +1,145 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// AgentInstance represents a fully configured agent with its own workspace, +// session manager, context builder, and tool registry. +type AgentInstance struct { + ID string + Name string + Model string + Fallbacks []string + Workspace string + MaxIterations int + ContextWindow int + Provider providers.LLMProvider + Sessions *session.SessionManager + ContextBuilder *ContextBuilder + Tools *tools.ToolRegistry + Subagents *config.SubagentsConfig + SkillsFilter []string + Candidates []providers.FallbackCandidate +} + +// NewAgentInstance creates an agent instance from config. +func NewAgentInstance( + agentCfg *config.AgentConfig, + defaults *config.AgentDefaults, + cfg *config.Config, + provider providers.LLMProvider, +) *AgentInstance { + workspace := resolveAgentWorkspace(agentCfg, defaults) + os.MkdirAll(workspace, 0755) + + model := resolveAgentModel(agentCfg, defaults) + fallbacks := resolveAgentFallbacks(agentCfg, defaults) + + restrict := defaults.RestrictToWorkspace + toolsRegistry := tools.NewToolRegistry() + toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict)) + toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict)) + toolsRegistry.Register(tools.NewListDirTool(workspace, restrict)) + toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) + toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) + toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) + + sessionsDir := filepath.Join(workspace, "sessions") + sessionsManager := session.NewSessionManager(sessionsDir) + + contextBuilder := NewContextBuilder(workspace) + contextBuilder.SetToolsRegistry(toolsRegistry) + + agentID := routing.DefaultAgentID + agentName := "" + var subagents *config.SubagentsConfig + var skillsFilter []string + + if agentCfg != nil { + agentID = routing.NormalizeAgentID(agentCfg.ID) + agentName = agentCfg.Name + subagents = agentCfg.Subagents + skillsFilter = agentCfg.Skills + } + + maxIter := defaults.MaxToolIterations + if maxIter == 0 { + maxIter = 20 + } + + // Resolve fallback candidates + modelCfg := providers.ModelConfig{ + Primary: model, + Fallbacks: fallbacks, + } + candidates := providers.ResolveCandidates(modelCfg, defaults.Provider) + + return &AgentInstance{ + ID: agentID, + Name: agentName, + Model: model, + Fallbacks: fallbacks, + Workspace: workspace, + MaxIterations: maxIter, + ContextWindow: defaults.MaxTokens, + Provider: provider, + Sessions: sessionsManager, + ContextBuilder: contextBuilder, + Tools: toolsRegistry, + Subagents: subagents, + SkillsFilter: skillsFilter, + Candidates: candidates, + } +} + +// resolveAgentWorkspace determines the workspace directory for an agent. +func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { + if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { + return expandHome(strings.TrimSpace(agentCfg.Workspace)) + } + if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" { + return expandHome(defaults.Workspace) + } + home, _ := os.UserHomeDir() + id := routing.NormalizeAgentID(agentCfg.ID) + return filepath.Join(home, ".picoclaw", "workspace-"+id) +} + +// resolveAgentModel resolves the primary model for an agent. +func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { + if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { + return strings.TrimSpace(agentCfg.Model.Primary) + } + return defaults.Model +} + +// resolveAgentFallbacks resolves the fallback models for an agent. +func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { + if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil { + return agentCfg.Model.Fallbacks + } + return defaults.ModelFallbacks +} + +func expandHome(path string) string { + if path == "" { + return path + } + if path[0] == '~' { + home, _ := os.UserHomeDir() + if len(path) > 1 && path[1] == '/' { + return home + path[1:] + } + return home + } + return path +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cc14ceaf0..ed69712ff 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -10,34 +10,33 @@ import ( "context" "encoding/json" "fmt" - "os" - "path/filepath" "strings" "sync" "sync/atomic" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/utils" ) type AgentLoop struct { bus *bus.MessageBus - provider providers.LLMProvider - workspace string - model string - contextWindow int // Maximum context window size in tokens - maxIterations int - sessions *session.SessionManager - contextBuilder *ContextBuilder - tools *tools.ToolRegistry + cfg *config.Config + registry *AgentRegistry + state *state.Manager running atomic.Bool - summarizing sync.Map // Tracks which sessions are currently being summarized + summarizing sync.Map + fallback *providers.FallbackChain + channelManager *channels.Manager } // processOptions configures how a message is processed @@ -49,60 +48,86 @@ type processOptions struct { DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus + NoHistory bool // If true, don't load session history (for heartbeat) } func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { - workspace := cfg.WorkspacePath() - os.MkdirAll(workspace, 0755) + registry := NewAgentRegistry(cfg, provider) - toolsRegistry := tools.NewToolRegistry() - toolsRegistry.Register(&tools.ReadFileTool{}) - toolsRegistry.Register(&tools.WriteFileTool{}) - toolsRegistry.Register(&tools.ListDirTool{}) - toolsRegistry.Register(tools.NewExecTool(workspace)) + // Register shared tools to all agents + registerSharedTools(cfg, msgBus, registry, provider) - braveAPIKey := cfg.Tools.Web.Search.APIKey - toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults)) - toolsRegistry.Register(tools.NewWebFetchTool(50000)) + // Set up shared fallback chain + cooldown := providers.NewCooldownTracker() + fallbackChain := providers.NewFallbackChain(cooldown) - // Register message tool - messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func(channel, chatID, content string) error { - msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, - }) - return nil - }) - toolsRegistry.Register(messageTool) - - // Register spawn tool - subagentManager := tools.NewSubagentManager(provider, workspace, msgBus) - spawnTool := tools.NewSpawnTool(subagentManager) - toolsRegistry.Register(spawnTool) - - // Register edit file tool - editFileTool := tools.NewEditFileTool(workspace) - toolsRegistry.Register(editFileTool) - - sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions")) - - // Create context builder and set tools registry - contextBuilder := NewContextBuilder(workspace) - contextBuilder.SetToolsRegistry(toolsRegistry) + // Create state manager using default agent's workspace for channel recording + defaultAgent := registry.GetDefaultAgent() + var stateManager *state.Manager + if defaultAgent != nil { + stateManager = state.NewManager(defaultAgent.Workspace) + } return &AgentLoop{ - bus: msgBus, - provider: provider, - workspace: workspace, - model: cfg.Agents.Defaults.Model, - contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization - maxIterations: cfg.Agents.Defaults.MaxToolIterations, - sessions: sessionsManager, - contextBuilder: contextBuilder, - tools: toolsRegistry, - summarizing: sync.Map{}, + bus: msgBus, + cfg: cfg, + registry: registry, + state: stateManager, + summarizing: sync.Map{}, + fallback: fallbackChain, + } +} + +// registerSharedTools registers tools that are shared across all agents (web, message, spawn). +func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) { + for _, agentID := range registry.ListAgentIDs() { + agent, ok := registry.GetAgent(agentID) + if !ok { + continue + } + + // Web tools + if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ + BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + }); searchTool != nil { + agent.Tools.Register(searchTool) + } + agent.Tools.Register(tools.NewWebFetchTool(50000)) + + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + agent.Tools.Register(tools.NewI2CTool()) + agent.Tools.Register(tools.NewSPITool()) + + // Message tool + messageTool := tools.NewMessageTool() + messageTool.SetSendCallback(func(channel, chatID, content string) error { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: content, + }) + return nil + }) + agent.Tools.Register(messageTool) + + // Spawn tool with allowlist checker + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) + spawnTool := tools.NewSpawnTool(subagentManager) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + agent.Tools.Register(spawnTool) + + // Update context builder with the complete tools registry + agent.ContextBuilder.SetToolsRegistry(agent.Tools) } } @@ -125,11 +150,26 @@ func (al *AgentLoop) Run(ctx context.Context) error { } if response != "" { - al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, - }) + // Check if the message tool already sent a response during this round. + // If so, skip publishing to avoid duplicate messages to the user. + // Use default agent's tools to check (message tool is shared). + alreadySent := false + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySent = mt.HasSentInRound() + } + } + } + + if !alreadySent { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: response, + }) + } } } } @@ -142,7 +182,33 @@ func (al *AgentLoop) Stop() { } func (al *AgentLoop) RegisterTool(tool tools.Tool) { - al.tools.Register(tool) + for _, agentID := range al.registry.ListAgentIDs() { + if agent, ok := al.registry.GetAgent(agentID); ok { + agent.Tools.Register(tool) + } + } +} + +func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { + al.channelManager = cm +} + +// RecordLastChannel records the last active channel for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. +func (al *AgentLoop) RecordLastChannel(channel string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChannel(channel) +} + +// RecordLastChatID records the last active chat ID for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. +func (al *AgentLoop) RecordLastChatID(chatID string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChatID(chatID) } func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { @@ -161,10 +227,31 @@ func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sess return al.processMessage(ctx, msg) } +// ProcessHeartbeat processes a heartbeat request without session history. +// Each heartbeat is independent and doesn't accumulate context. +func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { + agent := al.registry.GetDefaultAgent() + return al.runAgentLoop(ctx, agent, processOptions{ + SessionKey: "heartbeat", + Channel: channel, + ChatID: chatID, + UserMessage: content, + DefaultResponse: "I've completed processing but have no response to give.", + EnableSummary: false, + SendResponse: false, + NoHistory: true, // Don't load session history for heartbeat + }) +} + func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { - // Add message preview to log - preview := utils.Truncate(msg.Content, 80) - logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview), + // Add message preview to log (show full content for error messages) + var logContent string + if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { + logContent = msg.Content // Full content for errors + } else { + logContent = utils.Truncate(msg.Content, 80) + } + logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), map[string]interface{}{ "channel": msg.Channel, "chat_id": msg.ChatID, @@ -177,9 +264,41 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.processSystemMessage(ctx, msg) } - // Process as user message - return al.runAgentLoop(ctx, processOptions{ - SessionKey: msg.SessionKey, + // Check for commands + if response, handled := al.handleCommand(ctx, msg); handled { + return response, nil + } + + // Route to determine agent and session key + route := al.registry.ResolveRoute(routing.RouteInput{ + Channel: msg.Channel, + AccountID: msg.Metadata["account_id"], + Peer: extractPeer(msg), + ParentPeer: extractParentPeer(msg), + GuildID: msg.Metadata["guild_id"], + TeamID: msg.Metadata["team_id"], + }) + + agent, ok := al.registry.GetAgent(route.AgentID) + if !ok { + agent = al.registry.GetDefaultAgent() + } + + // Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron) + sessionKey := route.SessionKey + if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") { + sessionKey = msg.SessionKey + } + + logger.InfoCF("agent", "Routed message", + map[string]interface{}{ + "agent_id": agent.ID, + "session_key": sessionKey, + "matched_by": route.MatchedBy, + }) + + return al.runAgentLoop(ctx, agent, processOptions{ + SessionKey: sessionKey, Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, @@ -190,7 +309,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { - // Verify this is a system message if msg.Channel != "system" { return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel) } @@ -201,42 +319,75 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe "chat_id": msg.ChatID, }) - // Parse origin from chat_id (format: "channel:chat_id") + // Parse origin channel from chat_id (format: "channel:chat_id") var originChannel, originChatID string if idx := strings.Index(msg.ChatID, ":"); idx > 0 { originChannel = msg.ChatID[:idx] originChatID = msg.ChatID[idx+1:] } else { - // Fallback originChannel = "cli" originChatID = msg.ChatID } - // Use the origin session for context - sessionKey := fmt.Sprintf("%s:%s", originChannel, originChatID) + // Extract subagent result from message content + // Format: "Task 'label' completed.\n\nResult:\n" + content := msg.Content + if idx := strings.Index(content, "Result:\n"); idx >= 0 { + content = content[idx+8:] // Extract just the result part + } - // Process as system message with routing back to origin - return al.runAgentLoop(ctx, processOptions{ + // Skip internal channels - only log, don't send to user + if constants.IsInternalChannel(originChannel) { + logger.InfoCF("agent", "Subagent completed (internal channel)", + map[string]interface{}{ + "sender_id": msg.SenderID, + "content_len": len(content), + "channel": originChannel, + }) + return "", nil + } + + // Use default agent for system messages + agent := al.registry.GetDefaultAgent() + + // Use the origin session for context + sessionKey := routing.BuildAgentMainSessionKey(agent.ID) + + return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: originChannel, ChatID: originChatID, UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), DefaultResponse: "Background task completed.", EnableSummary: false, - SendResponse: true, // Send response back to original channel + SendResponse: true, }) } // runAgentLoop is the core message processing logic. -// It handles context building, LLM calls, tool execution, and response handling. -func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) { - // 1. Update tool contexts - al.updateToolContexts(opts.Channel, opts.ChatID) +func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) { + // 0. Record last channel for heartbeat notifications (skip internal channels) + if opts.Channel != "" && opts.ChatID != "" { + // Don't record internal channels (cli, system, subagent) + if !constants.IsInternalChannel(opts.Channel) { + channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) + if err := al.RecordLastChannel(channelKey); err != nil { + logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()}) + } + } + } - // 2. Build messages - history := al.sessions.GetHistory(opts.SessionKey) - summary := al.sessions.GetSummary(opts.SessionKey) - messages := al.contextBuilder.BuildMessages( + // 1. Update tool contexts + al.updateToolContexts(agent, opts.Channel, opts.ChatID) + + // 2. Build messages (skip history for heartbeat) + var history []providers.Message + var summary string + if !opts.NoHistory { + history = agent.Sessions.GetHistory(opts.SessionKey) + summary = agent.Sessions.GetSummary(opts.SessionKey) + } + messages := agent.ContextBuilder.BuildMessages( history, summary, opts.UserMessage, @@ -246,26 +397,29 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str ) // 3. Save user message to session - al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) + agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) // 4. Run LLM iteration loop - finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts) + finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) if err != nil { return "", err } + // If last tool had ForUser content and we already sent it, we might not need to send final response + // This is controlled by the tool's Silent flag and ForUser content + // 5. Handle empty response if finalContent == "" { finalContent = opts.DefaultResponse } // 6. Save final assistant message to session - al.sessions.AddMessage(opts.SessionKey, "assistant", finalContent) - al.sessions.Save(al.sessions.GetOrCreate(opts.SessionKey)) + agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) + agent.Sessions.Save(opts.SessionKey) // 7. Optional: summarization if opts.EnableSummary { - al.maybeSummarize(opts.SessionKey) + al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID) } // 8. Optional: send response via bus @@ -281,6 +435,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str responsePreview := utils.Truncate(finalContent, 120) logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), map[string]interface{}{ + "agent_id": agent.ID, "session_key": opts.SessionKey, "iterations": iteration, "final_length": len(finalContent), @@ -290,39 +445,29 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str } // runLLMIteration executes the LLM call loop with tool handling. -// Returns the final content, iteration count, and any error. -func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions) (string, int, error) { +func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) { iteration := 0 var finalContent string - for iteration < al.maxIterations { + for iteration < agent.MaxIterations { iteration++ logger.DebugCF("agent", "LLM iteration", map[string]interface{}{ + "agent_id": agent.ID, "iteration": iteration, - "max": al.maxIterations, + "max": agent.MaxIterations, }) // Build tool definitions - toolDefs := al.tools.GetDefinitions() - providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs)) - for _, td := range toolDefs { - providerToolDefs = append(providerToolDefs, providers.ToolDefinition{ - Type: td["type"].(string), - Function: providers.ToolFunctionDefinition{ - Name: td["function"].(map[string]interface{})["name"].(string), - Description: td["function"].(map[string]interface{})["description"].(string), - Parameters: td["function"].(map[string]interface{})["parameters"].(map[string]interface{}), - }, - }) - } + providerToolDefs := agent.Tools.ToProviderDefs() // Log LLM request details logger.DebugCF("agent", "LLM request", map[string]interface{}{ + "agent_id": agent.ID, "iteration": iteration, - "model": al.model, + "model": agent.Model, "messages_count": len(messages), "tools_count": len(providerToolDefs), "max_tokens": 8192, @@ -338,19 +483,84 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M "tools_json": formatToolsForLog(providerToolDefs), }) - // Call LLM - response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{ - "max_tokens": 8192, - "temperature": 0.7, - }) + // Call LLM with fallback chain if candidates are configured. + var response *providers.LLMResponse + var err error + + callLLM := func() (*providers.LLMResponse, error) { + if len(agent.Candidates) > 1 && al.fallback != nil { + fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates, + func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { + return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{ + "max_tokens": 8192, + "temperature": 0.7, + }) + }, + ) + if fbErr != nil { + return nil, fbErr + } + if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { + logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]interface{}{"agent_id": agent.ID, "iteration": iteration}) + } + return fbResult.Response, nil + } + return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{ + "max_tokens": 8192, + "temperature": 0.7, + }) + } + + // Retry loop for context/token errors + maxRetries := 2 + for retry := 0; retry <= maxRetries; retry++ { + response, err = callLLM() + if err == nil { + break + } + + errMsg := strings.ToLower(err.Error()) + isContextError := strings.Contains(errMsg, "token") || + strings.Contains(errMsg, "context") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "length") + + if isContextError && retry < maxRetries { + logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{ + "error": err.Error(), + "retry": retry, + }) + + if retry == 0 && !constants.IsInternalChannel(opts.Channel) { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: "Context window exceeded. Compressing history and retrying...", + }) + } + + al.forceCompression(agent, opts.SessionKey) + newHistory := agent.Sessions.GetHistory(opts.SessionKey) + newSummary := agent.Sessions.GetSummary(opts.SessionKey) + messages = agent.ContextBuilder.BuildMessages( + newHistory, newSummary, "", + nil, opts.Channel, opts.ChatID, + ) + continue + } + break + } if err != nil { logger.ErrorCF("agent", "LLM call failed", map[string]interface{}{ + "agent_id": agent.ID, "iteration": iteration, "error": err.Error(), }) - return "", iteration, fmt.Errorf("LLM call failed: %w", err) + return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } // Check if no tool calls - we're done @@ -358,6 +568,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M finalContent = response.Content logger.InfoCF("agent", "LLM response without tool calls (direct answer)", map[string]interface{}{ + "agent_id": agent.ID, "iteration": iteration, "content_chars": len(finalContent), }) @@ -371,8 +582,9 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M } logger.InfoCF("agent", "LLM requested tool calls", map[string]interface{}{ + "agent_id": agent.ID, "tools": toolNames, - "count": len(toolNames), + "count": len(response.ToolCalls), "iteration": iteration, }) @@ -395,33 +607,66 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M messages = append(messages, assistantMsg) // Save assistant message with tool calls to session - al.sessions.AddFullMessage(opts.SessionKey, assistantMsg) + agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) // Execute tool calls for _, tc := range response.ToolCalls { - // Log tool call with arguments preview argsJSON, _ := json.Marshal(tc.Arguments) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), map[string]interface{}{ + "agent_id": agent.ID, "tool": tc.Name, "iteration": iteration, }) - result, err := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID) - if err != nil { - result = fmt.Sprintf("Error: %v", err) + // Create async callback for tools that implement AsyncTool + // NOTE: Following openclaw's design, async tools do NOT send results directly to users. + // Instead, they notify the agent via PublishInbound, and the agent decides + // whether to forward the result to the user (in processSystemMessage). + asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) { + // Log the async completion but don't send directly to user + // The agent will handle user notification via processSystemMessage + if !result.Silent && result.ForUser != "" { + logger.InfoCF("agent", "Async tool completed, agent will handle notification", + map[string]interface{}{ + "tool": tc.Name, + "content_len": len(result.ForUser), + }) + } + } + + toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) + + // Send ForUser content to user immediately if not Silent + if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: toolResult.ForUser, + }) + logger.DebugCF("agent", "Sent tool result to user", + map[string]interface{}{ + "tool": tc.Name, + "content_len": len(toolResult.ForUser), + }) + } + + // Determine content for LLM based on tool result + contentForLLM := toolResult.ForLLM + if contentForLLM == "" && toolResult.Err != nil { + contentForLLM = toolResult.Err.Error() } toolResultMsg := providers.Message{ Role: "tool", - Content: result, + Content: contentForLLM, ToolCallID: tc.ID, } messages = append(messages, toolResultMsg) // Save tool result message to session - al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg) + agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) } } @@ -429,48 +674,133 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M } // updateToolContexts updates the context for tools that need channel/chatID info. -func (al *AgentLoop) updateToolContexts(channel, chatID string) { - if tool, ok := al.tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { +func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) { + // Use ContextualTool interface instead of type assertions + if tool, ok := agent.Tools.Get("message"); ok { + if mt, ok := tool.(tools.ContextualTool); ok { mt.SetContext(channel, chatID) } } - if tool, ok := al.tools.Get("spawn"); ok { - if st, ok := tool.(*tools.SpawnTool); ok { + if tool, ok := agent.Tools.Get("spawn"); ok { + if st, ok := tool.(tools.ContextualTool); ok { + st.SetContext(channel, chatID) + } + } + if tool, ok := agent.Tools.Get("subagent"); ok { + if st, ok := tool.(tools.ContextualTool); ok { st.SetContext(channel, chatID) } } } // maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize(sessionKey string) { - newHistory := al.sessions.GetHistory(sessionKey) +func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { + newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) - threshold := al.contextWindow * 75 / 100 + threshold := agent.ContextWindow * 75 / 100 if len(newHistory) > 20 || tokenEstimate > threshold { - if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading { + summarizeKey := agent.ID + ":" + sessionKey + if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { go func() { - defer al.summarizing.Delete(sessionKey) - al.summarizeSession(sessionKey) + defer al.summarizing.Delete(summarizeKey) + if !constants.IsInternalChannel(channel) { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: "Memory threshold reached. Optimizing conversation history...", + }) + } + al.summarizeSession(agent, sessionKey) }() } } } +// forceCompression aggressively reduces context when the limit is hit. +// It drops the oldest 50% of messages (keeping system prompt and last user message). +func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { + history := agent.Sessions.GetHistory(sessionKey) + if len(history) <= 4 { + return + } + + // Keep system prompt (usually [0]) and the very last message (user's trigger) + // We want to drop the oldest half of the *conversation* + // Assuming [0] is system, [1:] is conversation + conversation := history[1 : len(history)-1] + if len(conversation) == 0 { + return + } + + // Helper to find the mid-point of the conversation + mid := len(conversation) / 2 + + // New history structure: + // 1. System Prompt + // 2. [Summary of dropped part] - synthesized + // 3. Second half of conversation + // 4. Last message + + // Simplified approach for emergency: Drop first half of conversation + // and rely on existing summary if present, or create a placeholder. + + droppedCount := mid + keptConversation := conversation[mid:] + + newHistory := make([]providers.Message, 0) + newHistory = append(newHistory, history[0]) // System prompt + + // Add a note about compression + compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount) + // If there was an existing summary, we might lose it if it was in the dropped part (which is just messages). + // The summary is stored separately in session.Summary, so it persists! + // We just need to ensure the user knows there's a gap. + + // We only modify the messages list here + newHistory = append(newHistory, providers.Message{ + Role: "system", + Content: compressionNote, + }) + + newHistory = append(newHistory, keptConversation...) + newHistory = append(newHistory, history[len(history)-1]) // Last message + + // Update session + agent.Sessions.SetHistory(sessionKey, newHistory) + agent.Sessions.Save(sessionKey) + + logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{ + "session_key": sessionKey, + "dropped_msgs": droppedCount, + "new_count": len(newHistory), + }) +} + // GetStartupInfo returns information about loaded tools and skills for logging. func (al *AgentLoop) GetStartupInfo() map[string]interface{} { info := make(map[string]interface{}) + agent := al.registry.GetDefaultAgent() + if agent == nil { + return info + } + // Tools info - tools := al.tools.List() + toolsList := agent.Tools.List() info["tools"] = map[string]interface{}{ - "count": len(tools), - "names": tools, + "count": len(toolsList), + "names": toolsList, } // Skills info - info["skills"] = al.contextBuilder.GetSkillsInfo() + info["skills"] = agent.ContextBuilder.GetSkillsInfo() + + // Agents info + info["agents"] = map[string]interface{}{ + "count": len(al.registry.ListAgentIDs()), + "ids": al.registry.ListAgentIDs(), + } return info } @@ -485,7 +815,7 @@ func formatMessagesForLog(messages []providers.Message) string { result += "[\n" for i, msg := range messages { result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role) - if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 { + if len(msg.ToolCalls) > 0 { result += " ToolCalls:\n" for _, tc := range msg.ToolCalls { result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) @@ -527,12 +857,12 @@ func formatToolsForLog(tools []providers.ToolDefinition) string { } // summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession(sessionKey string) { +func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() - history := al.sessions.GetHistory(sessionKey) - summary := al.sessions.GetSummary(sessionKey) + history := agent.Sessions.GetHistory(sessionKey) + summary := agent.Sessions.GetSummary(sessionKey) // Keep last 4 messages for continuity if len(history) <= 4 { @@ -542,8 +872,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { toSummarize := history[:len(history)-4] // Oversized Message Guard - // Skip messages larger than 50% of context window to prevent summarizer overflow - maxMessageTokens := al.contextWindow / 2 + maxMessageTokens := agent.ContextWindow / 2 validMessages := make([]providers.Message, 0) omitted := false @@ -551,8 +880,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { if m.Role != "user" && m.Role != "assistant" { continue } - // Estimate tokens for this message - msgTokens := len(m.Content) / 4 + msgTokens := len(m.Content) / 2 if msgTokens > maxMessageTokens { omitted = true continue @@ -565,19 +893,17 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { } // Multi-Part Summarization - // Split into two parts if history is significant var finalSummary string if len(validMessages) > 10 { mid := len(validMessages) / 2 part1 := validMessages[:mid] part2 := validMessages[mid:] - s1, _ := al.summarizeBatch(ctx, part1, "") - s2, _ := al.summarizeBatch(ctx, part2, "") + s1, _ := al.summarizeBatch(ctx, agent, part1, "") + s2, _ := al.summarizeBatch(ctx, agent, part2, "") - // Merge them mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2) - resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{ + resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{ "max_tokens": 1024, "temperature": 0.3, }) @@ -587,7 +913,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { finalSummary = s1 + " " + s2 } } else { - finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary) + finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) } if omitted && finalSummary != "" { @@ -595,14 +921,14 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { } if finalSummary != "" { - al.sessions.SetSummary(sessionKey, finalSummary) - al.sessions.TruncateHistory(sessionKey, 4) - al.sessions.Save(al.sessions.GetOrCreate(sessionKey)) + agent.Sessions.SetSummary(sessionKey, finalSummary) + agent.Sessions.TruncateHistory(sessionKey, 4) + agent.Sessions.Save(sessionKey) } } // summarizeBatch summarizes a batch of messages. -func (al *AgentLoop) summarizeBatch(ctx context.Context, 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" if existingSummary != "" { prompt += "Existing context: " + existingSummary + "\n" @@ -612,7 +938,7 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content) } - response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{ + response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{ "max_tokens": 1024, "temperature": 0.3, }) @@ -623,10 +949,130 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa } // estimateTokens estimates the number of tokens in a message list. +// Uses a safe heuristic of 2.5 characters per token to account for CJK and other +// overheads better than the previous 3 chars/token. func (al *AgentLoop) estimateTokens(messages []providers.Message) int { - total := 0 + totalChars := 0 for _, m := range messages { - total += len(m.Content) / 4 // Simple heuristic: 4 chars per token + totalChars += utf8.RuneCountInString(m.Content) } - return total + // 2.5 chars per token = totalChars * 2 / 5 + return totalChars * 2 / 5 +} + +func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { + content := strings.TrimSpace(msg.Content) + if !strings.HasPrefix(content, "/") { + return "", false + } + + parts := strings.Fields(content) + if len(parts) == 0 { + return "", false + } + + cmd := parts[0] + args := parts[1:] + + switch cmd { + case "/show": + if len(args) < 1 { + return "Usage: /show [model|channel|agents]", true + } + switch args[0] { + case "model": + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + return "No default agent configured", true + } + return fmt.Sprintf("Current model: %s", defaultAgent.Model), true + case "channel": + return fmt.Sprintf("Current channel: %s", msg.Channel), true + case "agents": + agentIDs := al.registry.ListAgentIDs() + return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true + default: + return fmt.Sprintf("Unknown show target: %s", args[0]), true + } + + case "/list": + if len(args) < 1 { + return "Usage: /list [models|channels|agents]", true + } + switch args[0] { + case "models": + return "Available models: configured in config.json per agent", true + case "channels": + if al.channelManager == nil { + return "Channel manager not initialized", true + } + channels := al.channelManager.GetEnabledChannels() + if len(channels) == 0 { + return "No channels enabled", true + } + return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true + case "agents": + agentIDs := al.registry.ListAgentIDs() + return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true + default: + return fmt.Sprintf("Unknown list target: %s", args[0]), true + } + + case "/switch": + if len(args) < 3 || args[1] != "to" { + return "Usage: /switch [model|channel] to ", true + } + target := args[0] + value := args[2] + + switch target { + case "model": + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + return "No default agent configured", true + } + oldModel := defaultAgent.Model + defaultAgent.Model = value + return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true + case "channel": + if al.channelManager == nil { + return "Channel manager not initialized", true + } + if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { + return fmt.Sprintf("Channel '%s' not found or not enabled", value), true + } + return fmt.Sprintf("Switched target channel to %s", value), true + default: + return fmt.Sprintf("Unknown switch target: %s", target), true + } + } + + return "", false +} + +// extractPeer extracts the routing peer from inbound message metadata. +func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { + peerKind := msg.Metadata["peer_kind"] + if peerKind == "" { + return nil + } + peerID := msg.Metadata["peer_id"] + if peerID == "" { + if peerKind == "direct" { + peerID = msg.SenderID + } else { + peerID = msg.ChatID + } + } + return &routing.RoutePeer{Kind: peerKind, ID: peerID} +} + +// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. +func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { + parentKind := msg.Metadata["parent_peer_kind"] + parentID := msg.Metadata["parent_peer_id"] + if parentKind == "" || parentID == "" { + return nil + } + return &routing.RoutePeer{Kind: parentKind, ID: parentID} } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go new file mode 100644 index 000000000..f2257973c --- /dev/null +++ b/pkg/agent/loop_test.go @@ -0,0 +1,630 @@ +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// mockProvider is a simple mock LLM provider for testing +type mockProvider struct{} + +func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: "Mock response", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *mockProvider) GetDefaultModel() string { + return "mock-model" +} + +func TestRecordLastChannel(t *testing.T) { + // Create temp workspace + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test config + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + // Create agent loop + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Test RecordLastChannel + testChannel := "test-channel" + err = al.RecordLastChannel(testChannel) + if err != nil { + t.Fatalf("RecordLastChannel failed: %v", err) + } + + // Verify channel was saved + lastChannel := al.state.GetLastChannel() + if lastChannel != testChannel { + t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel) + } + + // Verify persistence by creating a new agent loop + al2 := NewAgentLoop(cfg, msgBus, provider) + if al2.state.GetLastChannel() != testChannel { + t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel()) + } +} + +func TestRecordLastChatID(t *testing.T) { + // Create temp workspace + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test config + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + // Create agent loop + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Test RecordLastChatID + testChatID := "test-chat-id-123" + err = al.RecordLastChatID(testChatID) + if err != nil { + t.Fatalf("RecordLastChatID failed: %v", err) + } + + // Verify chat ID was saved + lastChatID := al.state.GetLastChatID() + if lastChatID != testChatID { + t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID) + } + + // Verify persistence by creating a new agent loop + al2 := NewAgentLoop(cfg, msgBus, provider) + if al2.state.GetLastChatID() != testChatID { + t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID()) + } +} + +func TestNewAgentLoop_StateInitialized(t *testing.T) { + // Create temp workspace + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test config + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + // Create agent loop + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Verify state manager is initialized + if al.state == nil { + t.Error("Expected state manager to be initialized") + } + + // Verify state directory was created + stateDir := filepath.Join(tmpDir, "state") + if _, err := os.Stat(stateDir); os.IsNotExist(err) { + t.Error("Expected state directory to exist") + } +} + +// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved +func TestToolRegistry_ToolRegistration(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Register a custom tool + customTool := &mockCustomTool{} + al.RegisterTool(customTool) + + // Verify tool is registered by checking it doesn't panic on GetStartupInfo + // (actual tool retrieval is tested in tools package tests) + info := al.GetStartupInfo() + toolsInfo := info["tools"].(map[string]interface{}) + toolsList := toolsInfo["names"].([]string) + + // Check that our custom tool name is in the list + found := false + for _, name := range toolsList { + if name == "mock_custom" { + found = true + break + } + } + if !found { + t.Error("Expected custom tool to be registered") + } +} + +// TestToolContext_Updates verifies tool context is updated with channel/chatID +func TestToolContext_Updates(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "OK"} + _ = NewAgentLoop(cfg, msgBus, provider) + + // Verify that ContextualTool interface is defined and can be implemented + // This test validates the interface contract exists + ctxTool := &mockContextualTool{} + + // Verify the tool implements the interface correctly + var _ tools.ContextualTool = ctxTool +} + +// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved +func TestToolRegistry_GetDefinitions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Register a test tool and verify it shows up in startup info + testTool := &mockCustomTool{} + al.RegisterTool(testTool) + + info := al.GetStartupInfo() + toolsInfo := info["tools"].(map[string]interface{}) + toolsList := toolsInfo["names"].([]string) + + // Check that our custom tool name is in the list + found := false + for _, name := range toolsList { + if name == "mock_custom" { + found = true + break + } + } + if !found { + t.Error("Expected custom tool to be registered") + } +} + +// TestAgentLoop_GetStartupInfo verifies startup info contains tools +func TestAgentLoop_GetStartupInfo(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + info := al.GetStartupInfo() + + // Verify tools info exists + toolsInfo, ok := info["tools"] + if !ok { + t.Fatal("Expected 'tools' key in startup info") + } + + toolsMap, ok := toolsInfo.(map[string]interface{}) + if !ok { + t.Fatal("Expected 'tools' to be a map") + } + + count, ok := toolsMap["count"] + if !ok { + t.Fatal("Expected 'count' in tools info") + } + + // Should have default tools registered + if count.(int) == 0 { + t.Error("Expected at least some tools to be registered") + } +} + +// TestAgentLoop_Stop verifies Stop() sets running to false +func TestAgentLoop_Stop(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + // Note: running is only set to true when Run() is called + // We can't test that without starting the event loop + // Instead, verify the Stop method can be called safely + al.Stop() + + // Verify running is false (initial state or after Stop) + if al.running.Load() { + t.Error("Expected agent to be stopped (or never started)") + } +} + +// Mock implementations for testing + +type simpleMockProvider struct { + response string +} + +func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *simpleMockProvider) GetDefaultModel() string { + return "mock-model" +} + +// mockCustomTool is a simple mock tool for registration testing +type mockCustomTool struct{} + +func (m *mockCustomTool) Name() string { + return "mock_custom" +} + +func (m *mockCustomTool) Description() string { + return "Mock custom tool for testing" +} + +func (m *mockCustomTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } +} + +func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { + return tools.SilentResult("Custom tool executed") +} + +// mockContextualTool tracks context updates +type mockContextualTool struct { + lastChannel string + lastChatID string +} + +func (m *mockContextualTool) Name() string { + return "mock_contextual" +} + +func (m *mockContextualTool) Description() string { + return "Mock contextual tool" +} + +func (m *mockContextualTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } +} + +func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult { + return tools.SilentResult("Contextual tool executed") +} + +func (m *mockContextualTool) SetContext(channel, chatID string) { + m.lastChannel = channel + m.lastChatID = chatID +} + +// testHelper executes a message and returns the response +type testHelper struct { + al *AgentLoop +} + +func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { + // Use a short timeout to avoid hanging + timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) + defer cancel() + + response, err := h.al.processMessage(timeoutCtx, msg) + if err != nil { + tb.Fatalf("processMessage failed: %v", err) + } + return response +} + +const responseTimeout = 3 * time.Second + +// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound +func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "File operation complete"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + // ReadFileTool returns SilentResult, which should not send user message + ctx := context.Background() + msg := bus.InboundMessage{ + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "read test.txt", + SessionKey: "test-session", + } + + response := helper.executeAndGetResponse(t, ctx, msg) + + // Silent tool should return the LLM's response directly + if response != "File operation complete" { + t.Errorf("Expected 'File operation complete', got: %s", response) + } +} + +// TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound +func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "Command output: hello world"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + // ExecTool returns UserResult, which should send user message + ctx := context.Background() + msg := bus.InboundMessage{ + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "run hello", + SessionKey: "test-session", + } + + response := helper.executeAndGetResponse(t, ctx, msg) + + // User-facing tool should include the output in final response + if response != "Command output: hello world" { + t.Errorf("Expected 'Command output: hello world', got: %s", response) + } +} + +// failFirstMockProvider fails on the first N calls with a specific error +type failFirstMockProvider struct { + failures int + currentCall int + failError error + successResp string +} + +func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) { + m.currentCall++ + if m.currentCall <= m.failures { + return nil, m.failError + } + return &providers.LLMResponse{ + Content: m.successResp, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *failFirstMockProvider) GetDefaultModel() string { + return "mock-fail-model" +} + +// TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors +func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + // Create a provider that fails once with a context error + contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens") + provider := &failFirstMockProvider{ + failures: 1, + failError: contextErr, + successResp: "Recovered from context error", + } + + al := NewAgentLoop(cfg, msgBus, provider) + + // Inject some history to simulate a full context + sessionKey := "test-session-context" + // Create dummy history + history := []providers.Message{ + {Role: "system", Content: "System prompt"}, + {Role: "user", Content: "Old message 1"}, + {Role: "assistant", Content: "Old response 1"}, + {Role: "user", Content: "Old message 2"}, + {Role: "assistant", Content: "Old response 2"}, + {Role: "user", Content: "Trigger message"}, + } + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + defaultAgent.Sessions.SetHistory(sessionKey, history) + + // Call ProcessDirectWithChannel + // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration + response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat") + + if err != nil { + t.Fatalf("Expected success after retry, got error: %v", err) + } + + if response != "Recovered from context error" { + t.Errorf("Expected 'Recovered from context error', got '%s'", response) + } + + // We expect 2 calls: 1st failed, 2nd succeeded + if provider.currentCall != 2 { + t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall) + } + + // Check final history length + finalHistory := defaultAgent.Sessions.GetHistory(sessionKey) + // We verify that the history has been modified (compressed) + // Original length: 6 + // Expected behavior: compression drops ~50% of history (mid slice) + // We can assert that the length is NOT what it would be without compression. + // Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8 + if len(finalHistory) >= 8 { + t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) + } +} diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index f27882d1c..3f6896f91 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -40,8 +40,8 @@ func NewMemoryStore(workspace string) *MemoryStore { // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md). func (ms *MemoryStore) getTodayFile() string { - today := time.Now().Format("20060102") // YYYYMMDD - monthDir := today[:6] // YYYYMM + today := time.Now().Format("20060102") // YYYYMMDD + monthDir := today[:6] // YYYYMM filePath := filepath.Join(ms.memoryDir, monthDir, today+".md") return filePath } @@ -104,8 +104,8 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { for i := 0; i < days; i++ { date := time.Now().AddDate(0, 0, -i) - dateStr := date.Format("20060102") // YYYYMMDD - monthDir := dateStr[:6] // YYYYMM + dateStr := date.Format("20060102") // YYYYMMDD + monthDir := dateStr[:6] // YYYYMM filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md") if data, err := os.ReadFile(filePath); err == nil { diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go new file mode 100644 index 000000000..4cf5a6fca --- /dev/null +++ b/pkg/agent/registry.go @@ -0,0 +1,114 @@ +package agent + +import ( + "sync" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" +) + +// AgentRegistry manages multiple agent instances and routes messages to them. +type AgentRegistry struct { + agents map[string]*AgentInstance + resolver *routing.RouteResolver + mu sync.RWMutex +} + +// NewAgentRegistry creates a registry from config, instantiating all agents. +func NewAgentRegistry( + cfg *config.Config, + provider providers.LLMProvider, +) *AgentRegistry { + registry := &AgentRegistry{ + agents: make(map[string]*AgentInstance), + resolver: routing.NewRouteResolver(cfg), + } + + agentConfigs := cfg.Agents.List + if len(agentConfigs) == 0 { + implicitAgent := &config.AgentConfig{ + ID: "main", + Default: true, + } + instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) + registry.agents["main"] = instance + logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) + } else { + for i := range agentConfigs { + ac := &agentConfigs[i] + id := routing.NormalizeAgentID(ac.ID) + instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) + registry.agents[id] = instance + logger.InfoCF("agent", "Registered agent", + map[string]interface{}{ + "agent_id": id, + "name": ac.Name, + "workspace": instance.Workspace, + "model": instance.Model, + }) + } + } + + return registry +} + +// GetAgent returns the agent instance for a given ID. +func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + id := routing.NormalizeAgentID(agentID) + agent, ok := r.agents[id] + return agent, ok +} + +// ResolveRoute determines which agent handles the message. +func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute { + return r.resolver.ResolveRoute(input) +} + +// ListAgentIDs returns all registered agent IDs. +func (r *AgentRegistry) ListAgentIDs() []string { + r.mu.RLock() + defer r.mu.RUnlock() + ids := make([]string, 0, len(r.agents)) + for id := range r.agents { + ids = append(ids, id) + } + return ids +} + +// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID. +func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool { + parent, ok := r.GetAgent(parentAgentID) + if !ok { + return false + } + if parent.Subagents == nil || parent.Subagents.AllowAgents == nil { + return false + } + targetNorm := routing.NormalizeAgentID(targetAgentID) + for _, allowed := range parent.Subagents.AllowAgents { + if allowed == "*" { + return true + } + if routing.NormalizeAgentID(allowed) == targetNorm { + return true + } + } + return false +} + +// GetDefaultAgent returns the default agent instance. +func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { + r.mu.RLock() + defer r.mu.RUnlock() + if agent, ok := r.agents["main"]; ok { + return agent + } + for _, agent := range r.agents { + return agent + } + return nil +} diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go new file mode 100644 index 000000000..f196d7fb7 --- /dev/null +++ b/pkg/agent/registry_test.go @@ -0,0 +1,199 @@ +package agent + +import ( + "context" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type mockRegistryProvider struct{} + +func (m *mockRegistryProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil +} + +func (m *mockRegistryProvider) GetDefaultModel() string { + return "mock-model" +} + +func testCfg(agents []config.AgentConfig) *config.Config { + return &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: "/tmp/picoclaw-test-registry", + Model: "gpt-4", + MaxTokens: 8192, + MaxToolIterations: 10, + }, + List: agents, + }, + } +} + +func TestNewAgentRegistry_ImplicitMain(t *testing.T) { + cfg := testCfg(nil) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + ids := registry.ListAgentIDs() + if len(ids) != 1 || ids[0] != "main" { + t.Errorf("expected implicit main agent, got %v", ids) + } + + agent, ok := registry.GetAgent("main") + if !ok || agent == nil { + t.Fatal("expected to find 'main' agent") + } + if agent.ID != "main" { + t.Errorf("agent.ID = %q, want 'main'", agent.ID) + } +} + +func TestNewAgentRegistry_ExplicitAgents(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + {ID: "sales", Default: true, Name: "Sales Bot"}, + {ID: "support", Name: "Support Bot"}, + }) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + ids := registry.ListAgentIDs() + if len(ids) != 2 { + t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids) + } + + sales, ok := registry.GetAgent("sales") + if !ok || sales == nil { + t.Fatal("expected to find 'sales' agent") + } + if sales.Name != "Sales Bot" { + t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name) + } + + support, ok := registry.GetAgent("support") + if !ok || support == nil { + t.Fatal("expected to find 'support' agent") + } +} + +func TestAgentRegistry_GetAgent_Normalize(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + {ID: "my-agent", Default: true}, + }) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + agent, ok := registry.GetAgent("My-Agent") + if !ok || agent == nil { + t.Fatal("expected to find agent with normalized ID") + } + if agent.ID != "my-agent" { + t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID) + } +} + +func TestAgentRegistry_GetDefaultAgent(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + {ID: "alpha"}, + {ID: "beta", Default: true}, + }) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + // GetDefaultAgent first checks for "main", then returns any + agent := registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected a default agent") + } +} + +func TestAgentRegistry_CanSpawnSubagent(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + { + ID: "parent", + Default: true, + Subagents: &config.SubagentsConfig{ + AllowAgents: []string{"child1", "child2"}, + }, + }, + {ID: "child1"}, + {ID: "child2"}, + {ID: "restricted"}, + }) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + if !registry.CanSpawnSubagent("parent", "child1") { + t.Error("expected parent to be allowed to spawn child1") + } + if !registry.CanSpawnSubagent("parent", "child2") { + t.Error("expected parent to be allowed to spawn child2") + } + if registry.CanSpawnSubagent("parent", "restricted") { + t.Error("expected parent to NOT be allowed to spawn restricted") + } + if registry.CanSpawnSubagent("child1", "child2") { + t.Error("expected child1 to NOT be allowed to spawn (no subagents config)") + } +} + +func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + { + ID: "admin", + Default: true, + Subagents: &config.SubagentsConfig{ + AllowAgents: []string{"*"}, + }, + }, + {ID: "any-agent"}, + }) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + if !registry.CanSpawnSubagent("admin", "any-agent") { + t.Error("expected wildcard to allow spawning any agent") + } + if !registry.CanSpawnSubagent("admin", "nonexistent") { + t.Error("expected wildcard to allow spawning even nonexistent agents") + } +} + +func TestAgentInstance_Model(t *testing.T) { + model := &config.AgentModelConfig{Primary: "claude-opus"} + cfg := testCfg([]config.AgentConfig{ + {ID: "custom", Default: true, Model: model}, + }) + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + agent, _ := registry.GetAgent("custom") + if agent.Model != "claude-opus" { + t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model) + } +} + +func TestAgentInstance_FallbackInheritance(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + {ID: "inherit", Default: true}, + }) + cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"} + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + agent, _ := registry.GetAgent("inherit") + if len(agent.Fallbacks) != 2 { + t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks)) + } +} + +func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { + model := &config.AgentModelConfig{ + Primary: "gpt-4", + Fallbacks: []string{}, // explicitly empty = disable + } + cfg := testCfg([]config.AgentConfig{ + {ID: "no-fallback", Default: true, Model: model}, + }) + cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"} + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + agent, _ := registry.GetAgent("no-fallback") + if len(agent.Fallbacks) != 0 { + t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks) + } +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index ecd9ba265..dcd91bebd 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -19,18 +19,20 @@ import ( ) type OAuthProviderConfig struct { - Issuer string - ClientID string - Scopes string - Port int + Issuer string + ClientID string + Scopes string + Originator string + Port int } func OpenAIOAuthConfig() OAuthProviderConfig { return OAuthProviderConfig{ - Issuer: "https://auth.openai.com", - ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", - Scopes: "openid profile email offline_access", - Port: 1455, + Issuer: "https://auth.openai.com", + ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", + Scopes: "openid profile email offline_access", + Originator: "codex_cli_rs", + Port: 1455, } } @@ -279,7 +281,17 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre return nil, fmt.Errorf("token refresh failed: %s", string(body)) } - return parseTokenResponse(body, cred.Provider) + refreshed, err := parseTokenResponse(body, cred.Provider) + if err != nil { + return nil, err + } + if refreshed.RefreshToken == "" { + refreshed.RefreshToken = cred.RefreshToken + } + if refreshed.AccountID == "" { + refreshed.AccountID = cred.AccountID + } + return refreshed, nil } func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { @@ -288,15 +300,23 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { params := url.Values{ - "response_type": {"code"}, - "client_id": {cfg.ClientID}, - "redirect_uri": {redirectURI}, - "scope": {cfg.Scopes}, - "code_challenge": {pkce.CodeChallenge}, - "code_challenge_method": {"S256"}, - "state": {state}, + "response_type": {"code"}, + "client_id": {cfg.ClientID}, + "redirect_uri": {redirectURI}, + "scope": {cfg.Scopes}, + "code_challenge": {pkce.CodeChallenge}, + "code_challenge_method": {"S256"}, + "id_token_add_organizations": {"true"}, + "codex_cli_simplified_flow": {"true"}, + "state": {state}, } - return cfg.Issuer + "/authorize?" + params.Encode() + if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") { + params.Set("originator", "picoclaw") + } + if cfg.Originator != "" { + params.Set("originator", cfg.Originator) + } + return cfg.Issuer + "/oauth/authorize?" + params.Encode() } func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { @@ -350,19 +370,57 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { AuthMethod: "oauth", } - if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" { + if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { + cred.AccountID = accountID + } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" { + cred.AccountID = accountID + } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { + // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims. cred.AccountID = accountID } return cred, nil } -func extractAccountID(accessToken string) string { - parts := strings.Split(accessToken, ".") - if len(parts) < 2 { +func extractAccountID(token string) string { + claims, err := parseJWTClaims(token) + if err != nil { return "" } + if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" { + return accountID + } + + if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" { + return accountID + } + + if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok { + if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" { + return accountID + } + } + + if orgs, ok := claims["organizations"].([]interface{}); ok { + for _, org := range orgs { + if orgMap, ok := org.(map[string]interface{}); ok { + if accountID, ok := orgMap["id"].(string); ok && accountID != "" { + return accountID + } + } + } + } + + return "" +} + +func parseJWTClaims(token string) (map[string]interface{}, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("token is not a JWT") + } + payload := parts[1] switch len(payload) % 4 { case 2: @@ -373,21 +431,15 @@ func extractAccountID(accessToken string) string { decoded, err := base64URLDecode(payload) if err != nil { - return "" + return nil, err } var claims map[string]interface{} if err := json.Unmarshal(decoded, &claims); err != nil { - return "" + return nil, err } - if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok { - if accountID, ok := authClaim["chatgpt_account_id"].(string); ok { - return accountID - } - } - - return "" + return claims, nil } func base64URLDecode(s string) ([]byte, error) { diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 9f8013217..5deb17805 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -1,19 +1,34 @@ package auth import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" + "net/url" "strings" "testing" ) +func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string { + t.Helper() + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payloadJSON, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadJSON) + return header + "." + payload + ".sig" +} + func TestBuildAuthorizeURL(t *testing.T) { cfg := OAuthProviderConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client-id", - Scopes: "openid profile", - Port: 1455, + Issuer: "https://auth.example.com", + ClientID: "test-client-id", + Scopes: "openid profile", + Originator: "codex_cli_rs", + Port: 1455, } pkce := PKCECodes{ CodeVerifier: "test-verifier", @@ -22,7 +37,7 @@ func TestBuildAuthorizeURL(t *testing.T) { u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback") - if !strings.HasPrefix(u, "https://auth.example.com/authorize?") { + if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") { t.Errorf("URL does not start with expected prefix: %s", u) } if !strings.Contains(u, "client_id=test-client-id") { @@ -40,6 +55,37 @@ func TestBuildAuthorizeURL(t *testing.T) { if !strings.Contains(u, "response_type=code") { t.Error("URL missing response_type") } + if !strings.Contains(u, "id_token_add_organizations=true") { + t.Error("URL missing id_token_add_organizations") + } + if !strings.Contains(u, "codex_cli_simplified_flow=true") { + t.Error("URL missing codex_cli_simplified_flow") + } + if !strings.Contains(u, "originator=codex_cli_rs") { + t.Error("URL missing originator") + } +} + +func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) { + cfg := OpenAIOAuthConfig() + pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"} + + u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback") + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("url.Parse() error: %v", err) + } + q := parsed.Query() + + if q.Get("id_token_add_organizations") != "true" { + t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations")) + } + if q.Get("codex_cli_simplified_flow") != "true" { + t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow")) + } + if q.Get("originator") != "codex_cli_rs" { + t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator")) + } } func TestParseTokenResponse(t *testing.T) { @@ -73,6 +119,37 @@ func TestParseTokenResponse(t *testing.T) { } } +func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) { + idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"}) + resp := map[string]interface{}{ + "access_token": "opaque-access-token", + "refresh_token": "test-refresh-token", + "expires_in": 3600, + "id_token": idToken, + } + body, _ := json.Marshal(resp) + + cred, err := parseTokenResponse(body, "openai") + if err != nil { + t.Fatalf("parseTokenResponse() error: %v", err) + } + if cred.AccountID != "acc-id-from-id-token" { + t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token") + } +} + +func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) { + token := makeJWTForClaims(t, map[string]interface{}{ + "organizations": []interface{}{ + map[string]interface{}{"id": "org_from_orgs"}, + }, + }) + + if got := extractAccountID(token); got != "org_from_orgs" { + t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs") + } +} + func TestParseTokenResponseNoAccessToken(t *testing.T) { body := []byte(`{"refresh_token": "test"}`) _, err := parseTokenResponse(body, "openai") @@ -81,6 +158,32 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) { } } +func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) { + idToken := makeJWTWithAccountID("acc-from-id") + resp := map[string]interface{}{ + "access_token": "not-a-jwt", + "refresh_token": "test-refresh-token", + "expires_in": 3600, + "id_token": idToken, + } + body, _ := json.Marshal(resp) + + cred, err := parseTokenResponse(body, "openai") + if err != nil { + t.Fatalf("parseTokenResponse() error: %v", err) + } + + if cred.AccountID != "acc-from-id" { + t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id") + } +} + +func makeJWTWithAccountID(accountID string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`)) + return header + "." + payload + ".sig" +} + func TestExchangeCodeForTokens(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/oauth/token" { @@ -185,6 +288,37 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) { } } +func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "access_token": "new-access-token-only", + "expires_in": 3600, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"} + cred := &AuthCredential{ + AccessToken: "old-access", + RefreshToken: "existing-refresh", + AccountID: "acc_existing", + Provider: "openai", + AuthMethod: "oauth", + } + + refreshed, err := RefreshAccessToken(cred, cfg) + if err != nil { + t.Fatalf("RefreshAccessToken() error: %v", err) + } + if refreshed.RefreshToken != "existing-refresh" { + t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh") + } + if refreshed.AccountID != "acc_existing" { + t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing") + } +} + func TestOpenAIOAuthConfig(t *testing.T) { cfg := OpenAIOAuthConfig() if cfg.Issuer != "https://auth.openai.com" { diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 6283251a4..58c0a25d5 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -9,6 +9,7 @@ type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage handlers map[string]MessageHandler + closed bool mu sync.RWMutex } @@ -21,6 +22,11 @@ func NewMessageBus() *MessageBus { } func (mb *MessageBus) PublishInbound(msg InboundMessage) { + mb.mu.RLock() + defer mb.mu.RUnlock() + if mb.closed { + return + } mb.inbound <- msg } @@ -34,6 +40,11 @@ func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) } func (mb *MessageBus) PublishOutbound(msg OutboundMessage) { + mb.mu.RLock() + defer mb.mu.RUnlock() + if mb.closed { + return + } mb.outbound <- msg } @@ -60,6 +71,12 @@ func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) { } func (mb *MessageBus) Close() { + mb.mu.Lock() + defer mb.mu.Unlock() + if mb.closed { + return + } + mb.closed = true close(mb.inbound) close(mb.outbound) } diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 3ade40062..4925099a3 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -2,7 +2,7 @@ package channels import ( "context" - "fmt" + "strings" "github.com/sipeed/picoclaw/pkg/bus" ) @@ -47,8 +47,33 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { return true } + // Extract parts from compound senderID like "123456|username" + idPart := senderID + userPart := "" + if idx := strings.Index(senderID, "|"); idx > 0 { + idPart = senderID[:idx] + userPart = senderID[idx+1:] + } + for _, allowed := range c.allowList { - if senderID == allowed { + // Strip leading "@" from allowed value for username matching + trimmed := strings.TrimPrefix(allowed, "@") + allowedID := trimmed + allowedUser := "" + if idx := strings.Index(trimmed, "|"); idx > 0 { + allowedID = trimmed[:idx] + allowedUser = trimmed[idx+1:] + } + + // Support either side using "id|username" compound form. + // This keeps backward compatibility with legacy Telegram allowlist entries. + if senderID == allowed || + idPart == allowed || + senderID == trimmed || + idPart == trimmed || + idPart == allowedID || + (allowedUser != "" && senderID == allowedUser) || + (userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) { return true } } @@ -61,17 +86,13 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st return } - // Build session key: channel:chatID - sessionKey := fmt.Sprintf("%s:%s", c.name, chatID) - msg := bus.InboundMessage{ - Channel: c.name, - SenderID: senderID, - ChatID: chatID, - Content: content, - Media: media, - SessionKey: sessionKey, - Metadata: metadata, + Channel: c.name, + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: media, + Metadata: metadata, } c.bus.PublishInbound(msg) diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go new file mode 100644 index 000000000..78c6d1d66 --- /dev/null +++ b/pkg/channels/base_test.go @@ -0,0 +1,52 @@ +package channels + +import "testing" + +func TestBaseChannelIsAllowed(t *testing.T) { + tests := []struct { + name string + allowList []string + senderID string + want bool + }{ + { + name: "empty allowlist allows all", + allowList: nil, + senderID: "anyone", + want: true, + }, + { + name: "compound sender matches numeric allowlist", + allowList: []string{"123456"}, + senderID: "123456|alice", + want: true, + }, + { + name: "compound sender matches username allowlist", + allowList: []string{"@alice"}, + senderID: "123456|alice", + want: true, + }, + { + name: "numeric sender matches legacy compound allowlist", + allowList: []string{"123456|alice"}, + senderID: "123456", + want: true, + }, + { + name: "non matching sender is denied", + allowList: []string{"123456"}, + senderID: "654321|bob", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := NewBaseChannel("test", nil, nil, tt.allowList) + if got := ch.IsAllowed(tt.senderID); got != tt.want { + t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want) + } + }) + } +} diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go index 5c6f29f0c..263785c0c 100644 --- a/pkg/channels/dingtalk.go +++ b/pkg/channels/dingtalk.go @@ -20,12 +20,12 @@ import ( // It uses WebSocket for receiving messages via stream mode and API for sending type DingTalkChannel struct { *BaseChannel - config config.DingTalkConfig - clientID string - clientSecret string - streamClient *client.StreamClient - ctx context.Context - cancel context.CancelFunc + config config.DingTalkConfig + clientID string + clientSecret string + streamClient *client.StreamClient + ctx context.Context + cancel context.CancelFunc // Map to store session webhooks for each chat sessionWebhooks sync.Map // chatID -> sessionWebhook } @@ -109,8 +109,8 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err } logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{ - "chat_id": msg.ChatID, - "preview": utils.Truncate(msg.Content, 100), + "chat_id": msg.ChatID, + "preview": utils.Truncate(msg.Content, 100), }) // Use the session webhook to send the reply diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index e65c99eec..f360c75ef 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "time" "github.com/bwmarrin/discordgo" @@ -100,15 +101,156 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("channel ID is empty") } - message := msg.Content + runes := []rune(msg.Content) + if len(runes) == 0 { + return nil + } + chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks + + for _, chunk := range chunks { + if err := c.sendChunk(ctx, channelID, chunk); err != nil { + return err + } + } + + return nil +} + +// splitMessage splits long messages into chunks, preserving code block integrity +// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks +func splitMessage(content string, limit int) []string { + var messages []string + + for len(content) > 0 { + if len(content) <= limit { + messages = append(messages, content) + break + } + + msgEnd := limit + + // Find natural split point within the limit + msgEnd = findLastNewline(content[:limit], 200) + if msgEnd <= 0 { + msgEnd = findLastSpace(content[:limit], 100) + } + if msgEnd <= 0 { + msgEnd = limit + } + + // Check if this would end with an incomplete code block + candidate := content[:msgEnd] + unclosedIdx := findLastUnclosedCodeBlock(candidate) + + if unclosedIdx >= 0 { + // Message would end with incomplete code block + // Try to extend to include the closing ``` (with some buffer) + extendedLimit := limit + 500 // Allow 500 char buffer for code blocks + if len(content) > extendedLimit { + closingIdx := findNextClosingCodeBlock(content, msgEnd) + if closingIdx > 0 && closingIdx <= extendedLimit { + // Extend to include the closing ``` + msgEnd = closingIdx + } else { + // Can't find closing, split before the code block + msgEnd = findLastNewline(content[:unclosedIdx], 200) + if msgEnd <= 0 { + msgEnd = findLastSpace(content[:unclosedIdx], 100) + } + if msgEnd <= 0 { + msgEnd = unclosedIdx + } + } + } else { + // Remaining content fits within extended limit + msgEnd = len(content) + } + } + + if msgEnd <= 0 { + msgEnd = limit + } + + messages = append(messages, content[:msgEnd]) + content = strings.TrimSpace(content[msgEnd:]) + } + + return messages +} + +// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` +// Returns the position of the opening ``` or -1 if all code blocks are complete +func findLastUnclosedCodeBlock(text string) int { + count := 0 + lastOpenIdx := -1 + + for i := 0; i < len(text); i++ { + if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { + if count == 0 { + lastOpenIdx = i + } + count++ + i += 2 + } + } + + // If odd number of ``` markers, last one is unclosed + if count%2 == 1 { + return lastOpenIdx + } + return -1 +} + +// findNextClosingCodeBlock finds the next closing ``` starting from a position +// Returns the position after the closing ``` or -1 if not found +func findNextClosingCodeBlock(text string, startIdx int) int { + for i := startIdx; i < len(text); i++ { + if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { + return i + 3 + } + } + return -1 +} + +// findLastNewline finds the last newline character within the last N characters +// Returns the position of the newline or -1 if not found +func findLastNewline(s string, searchWindow int) int { + searchStart := len(s) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(s) - 1; i >= searchStart; i-- { + if s[i] == '\n' { + return i + } + } + return -1 +} + +// findLastSpace finds the last space character within the last N characters +// Returns the position of the space or -1 if not found +func findLastSpace(s string, searchWindow int) int { + searchStart := len(s) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(s) - 1; i >= searchStart; i-- { + if s[i] == ' ' || s[i] == '\t' { + return i + } + } + return -1 +} + +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // 使用传入的 ctx 进行超时控制 sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() done := make(chan error, 1) go func() { - _, err := c.session.ChannelMessageSend(channelID, message) + _, err := c.session.ChannelMessageSend(channelID, content) done <- err }() @@ -140,6 +282,12 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } + if err := c.session.ChannelTyping(m.ChannelID); err != nil { + logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{ + "error": err.Error(), + }) + } + // 检查白名单,避免为被拒绝的用户下载附件和转录 if !c.IsAllowed(m.Author.ID) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ @@ -228,6 +376,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "preview": utils.Truncate(content, 50), }) + peerKind := "channel" + peerID := m.ChannelID + if m.GuildID == "" { + peerKind = "direct" + peerID = senderID + } + metadata := map[string]string{ "message_id": m.ID, "user_id": senderID, @@ -236,6 +391,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "guild_id": m.GuildID, "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), + "peer_kind": peerKind, + "peer_id": peerID, } c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go new file mode 100644 index 000000000..4e60fbc11 --- /dev/null +++ b/pkg/channels/feishu_32.go @@ -0,0 +1,36 @@ +//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 + +package channels + +import ( + "context" + "errors" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// FeishuChannel is a stub implementation for 32-bit architectures +type FeishuChannel struct { + *BaseChannel +} + +// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported +func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { + return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config") +} + +// Start is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Start(ctx context.Context) error { + return nil +} + +// Stop is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Stop(ctx context.Context) error { + return nil +} + +// Send is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + return errors.New("feishu channel is not supported on 32-bit architectures") +} diff --git a/pkg/channels/feishu.go b/pkg/channels/feishu_64.go similarity index 98% rename from pkg/channels/feishu.go rename to pkg/channels/feishu_64.go index 11dbd6748..39dc40ac1 100644 --- a/pkg/channels/feishu.go +++ b/pkg/channels/feishu_64.go @@ -1,3 +1,5 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + package channels import ( diff --git a/pkg/channels/line.go b/pkg/channels/line.go new file mode 100644 index 000000000..ffb5533e8 --- /dev/null +++ b/pkg/channels/line.go @@ -0,0 +1,598 @@ +package channels + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const ( + lineAPIBase = "https://api.line.me/v2/bot" + lineDataAPIBase = "https://api-data.line.me/v2/bot" + lineReplyEndpoint = lineAPIBase + "/message/reply" + linePushEndpoint = lineAPIBase + "/message/push" + lineContentEndpoint = lineDataAPIBase + "/message/%s/content" + lineBotInfoEndpoint = lineAPIBase + "/info" + lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" + lineReplyTokenMaxAge = 25 * time.Second +) + +type replyTokenEntry struct { + token string + timestamp time.Time +} + +// LINEChannel implements the Channel interface for LINE Official Account +// using the LINE Messaging API with HTTP webhook for receiving messages +// and REST API for sending messages. +type LINEChannel struct { + *BaseChannel + config config.LINEConfig + httpServer *http.Server + botUserID string // Bot's user ID + botBasicID string // Bot's basic ID (e.g. @216ru...) + botDisplayName string // Bot's display name for text-based mention detection + replyTokens sync.Map // chatID -> replyTokenEntry + quoteTokens sync.Map // chatID -> quoteToken (string) + ctx context.Context + cancel context.CancelFunc +} + +// NewLINEChannel creates a new LINE channel instance. +func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { + if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" { + return nil, fmt.Errorf("line channel_secret and channel_access_token are required") + } + + base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom) + + return &LINEChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// Start launches the HTTP webhook server. +func (c *LINEChannel) Start(ctx context.Context) error { + logger.InfoC("line", "Starting LINE channel (Webhook Mode)") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Fetch bot profile to get bot's userId for mention detection + if err := c.fetchBotInfo(); err != nil { + logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{ + "error": err.Error(), + }) + } else { + logger.InfoCF("line", "Bot info fetched", map[string]interface{}{ + "bot_user_id": c.botUserID, + "basic_id": c.botBasicID, + "display_name": c.botDisplayName, + }) + } + + mux := http.NewServeMux() + path := c.config.WebhookPath + if path == "" { + path = "/webhook/line" + } + mux.HandleFunc(path, c.webhookHandler) + + addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) + c.httpServer = &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{ + "addr": addr, + "path": path, + }) + if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("line", "Webhook server error", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + + c.setRunning(true) + logger.InfoC("line", "LINE channel started (Webhook Mode)") + return nil +} + +// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API. +func (c *LINEChannel) fetchBotInfo() error { + req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bot info API returned status %d", resp.StatusCode) + } + + var info struct { + UserID string `json:"userId"` + BasicID string `json:"basicId"` + DisplayName string `json:"displayName"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return err + } + + c.botUserID = info.UserID + c.botBasicID = info.BasicID + c.botDisplayName = info.DisplayName + return nil +} + +// Stop gracefully shuts down the HTTP server. +func (c *LINEChannel) Stop(ctx context.Context) error { + logger.InfoC("line", "Stopping LINE channel") + + if c.cancel != nil { + c.cancel() + } + + if c.httpServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := c.httpServer.Shutdown(shutdownCtx); err != nil { + logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{ + "error": err.Error(), + }) + } + } + + c.setRunning(false) + logger.InfoC("line", "LINE channel stopped") + return nil +} + +// webhookHandler handles incoming LINE webhook requests. +func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + signature := r.Header.Get("X-Line-Signature") + if !c.verifySignature(body, signature) { + logger.WarnC("line", "Invalid webhook signature") + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + var payload struct { + Events []lineEvent `json:"events"` + } + if err := json.Unmarshal(body, &payload); err != nil { + logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Return 200 immediately, process events asynchronously + w.WriteHeader(http.StatusOK) + + for _, event := range payload.Events { + go c.processEvent(event) + } +} + +// verifySignature validates the X-Line-Signature using HMAC-SHA256. +func (c *LINEChannel) verifySignature(body []byte, signature string) bool { + if signature == "" { + return false + } + + mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret)) + mac.Write(body) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// LINE webhook event types +type lineEvent struct { + Type string `json:"type"` + ReplyToken string `json:"replyToken"` + Source lineSource `json:"source"` + Message json.RawMessage `json:"message"` + Timestamp int64 `json:"timestamp"` +} + +type lineSource struct { + Type string `json:"type"` // "user", "group", "room" + UserID string `json:"userId"` + GroupID string `json:"groupId"` + RoomID string `json:"roomId"` +} + +type lineMessage struct { + ID string `json:"id"` + Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker" + Text string `json:"text"` + QuoteToken string `json:"quoteToken"` + Mention *struct { + Mentionees []lineMentionee `json:"mentionees"` + } `json:"mention"` + ContentProvider struct { + Type string `json:"type"` + } `json:"contentProvider"` +} + +type lineMentionee struct { + Index int `json:"index"` + Length int `json:"length"` + Type string `json:"type"` // "user", "all" + UserID string `json:"userId"` +} + +func (c *LINEChannel) processEvent(event lineEvent) { + if event.Type != "message" { + logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{ + "type": event.Type, + }) + return + } + + senderID := event.Source.UserID + chatID := c.resolveChatID(event.Source) + isGroup := event.Source.Type == "group" || event.Source.Type == "room" + + var msg lineMessage + if err := json.Unmarshal(event.Message, &msg); err != nil { + logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{ + "error": err.Error(), + }) + return + } + + // In group chats, only respond when the bot is mentioned + if isGroup && !c.isBotMentioned(msg) { + logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{ + "chat_id": chatID, + }) + return + } + + // Store reply token for later use + if event.ReplyToken != "" { + c.replyTokens.Store(chatID, replyTokenEntry{ + token: event.ReplyToken, + timestamp: time.Now(), + }) + } + + // Store quote token for quoting the original message in reply + if msg.QuoteToken != "" { + c.quoteTokens.Store(chatID, msg.QuoteToken) + } + + var content string + var mediaPaths []string + localFiles := []string{} + + defer func() { + for _, file := range localFiles { + if err := os.Remove(file); err != nil { + logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{ + "file": file, + "error": err.Error(), + }) + } + } + }() + + switch msg.Type { + case "text": + content = msg.Text + // Strip bot mention from text in group chats + if isGroup { + content = c.stripBotMention(content, msg) + } + case "image": + localPath := c.downloadContent(msg.ID, "image.jpg") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[image]" + } + case "audio": + localPath := c.downloadContent(msg.ID, "audio.m4a") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[audio]" + } + case "video": + localPath := c.downloadContent(msg.ID, "video.mp4") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[video]" + } + case "file": + content = "[file]" + case "sticker": + content = "[sticker]" + default: + content = fmt.Sprintf("[%s]", msg.Type) + } + + if strings.TrimSpace(content) == "" { + return + } + + metadata := map[string]string{ + "platform": "line", + "source_type": event.Source.Type, + "message_id": msg.ID, + } + + logger.DebugCF("line", "Received message", map[string]interface{}{ + "sender_id": senderID, + "chat_id": chatID, + "message_type": msg.Type, + "is_group": isGroup, + "preview": utils.Truncate(content, 50), + }) + + // Show typing/loading indicator (requires user ID, not group ID) + c.sendLoading(senderID) + + c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) +} + +// isBotMentioned checks if the bot is mentioned in the message. +// It first checks the mention metadata (userId match), then falls back +// to text-based detection using the bot's display name, since LINE may +// not include userId in mentionees for Official Accounts. +func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { + // Check mention metadata + if msg.Mention != nil { + for _, m := range msg.Mention.Mentionees { + if m.Type == "all" { + return true + } + if c.botUserID != "" && m.UserID == c.botUserID { + return true + } + } + // Mention metadata exists with mentionees but bot not matched by userId. + // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), + // so check if any mentionee overlaps with bot display name in text. + if c.botDisplayName != "" { + for _, m := range msg.Mention.Mentionees { + if m.Index >= 0 && m.Length > 0 { + runes := []rune(msg.Text) + end := m.Index + m.Length + if end <= len(runes) { + mentionText := string(runes[m.Index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + return true + } + } + } + } + } + } + + // Fallback: text-based detection with display name + if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) { + return true + } + + return false +} + +// stripBotMention removes the @BotName mention text from the message. +func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { + stripped := false + + // Try to strip using mention metadata indices + if msg.Mention != nil { + runes := []rune(text) + for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { + m := msg.Mention.Mentionees[i] + // Strip if userId matches OR if the mention text contains the bot display name + shouldStrip := false + if c.botUserID != "" && m.UserID == c.botUserID { + shouldStrip = true + } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { + end := m.Index + m.Length + if end <= len(runes) { + mentionText := string(runes[m.Index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + shouldStrip = true + } + } + } + if shouldStrip { + start := m.Index + end := m.Index + m.Length + if start >= 0 && end <= len(runes) { + runes = append(runes[:start], runes[end:]...) + stripped = true + } + } + } + if stripped { + return strings.TrimSpace(string(runes)) + } + } + + // Fallback: strip @DisplayName from text + if c.botDisplayName != "" { + text = strings.ReplaceAll(text, "@"+c.botDisplayName, "") + } + + return strings.TrimSpace(text) +} + +// resolveChatID determines the chat ID from the event source. +// For group/room messages, use the group/room ID; for 1:1, use the user ID. +func (c *LINEChannel) resolveChatID(source lineSource) string { + switch source.Type { + case "group": + return source.GroupID + case "room": + return source.RoomID + default: + return source.UserID + } +} + +// Send sends a message to LINE. It first tries the Reply API (free) +// using a cached reply token, then falls back to the Push API. +func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("line channel not running") + } + + // Load and consume quote token for this chat + var quoteToken string + if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok { + quoteToken = qt.(string) + } + + // Try reply token first (free, valid for ~25 seconds) + if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { + tokenEntry := entry.(replyTokenEntry) + if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { + if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { + logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{ + "chat_id": msg.ChatID, + "quoted": quoteToken != "", + }) + return nil + } + logger.DebugC("line", "Reply API failed, falling back to Push API") + } + } + + // Fall back to Push API + return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) +} + +// buildTextMessage creates a text message object, optionally with quoteToken. +func buildTextMessage(content, quoteToken string) map[string]string { + msg := map[string]string{ + "type": "text", + "text": content, + } + if quoteToken != "" { + msg["quoteToken"] = quoteToken + } + return msg +} + +// sendReply sends a message using the LINE Reply API. +func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { + payload := map[string]interface{}{ + "replyToken": replyToken, + "messages": []map[string]string{buildTextMessage(content, quoteToken)}, + } + + return c.callAPI(ctx, lineReplyEndpoint, payload) +} + +// sendPush sends a message using the LINE Push API. +func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { + payload := map[string]interface{}{ + "to": to, + "messages": []map[string]string{buildTextMessage(content, quoteToken)}, + } + + return c.callAPI(ctx, linePushEndpoint, payload) +} + +// sendLoading sends a loading animation indicator to the chat. +func (c *LINEChannel) sendLoading(chatID string) { + payload := map[string]interface{}{ + "chatId": chatID, + "loadingSeconds": 60, + } + if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { + logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{ + "error": err.Error(), + }) + } +} + +// callAPI makes an authenticated POST request to the LINE API. +func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// downloadContent downloads media content from the LINE API. +func (c *LINEChannel) downloadContent(messageID, filename string) string { + url := fmt.Sprintf(lineContentEndpoint, messageID) + return utils.DownloadFile(url, filename, utils.DownloadOptions{ + LoggerPrefix: "line", + ExtraHeaders: map[string]string{ + "Authorization": "Bearer " + c.config.ChannelAccessToken, + }, + }) +} diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go index 5fc19adbe..01e570b25 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam.go @@ -18,7 +18,6 @@ type MaixCamChannel struct { listener net.Listener clients map[net.Conn]bool clientsMux sync.RWMutex - running bool } type MaixCamMessage struct { @@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC BaseChannel: base, config: cfg, clients: make(map[net.Conn]bool), - running: false, }, nil } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index b0e1416b6..7f6abc4cb 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -13,6 +13,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -47,7 +48,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" { logger.DebugC("channels", "Attempting to initialize Telegram channel") - telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus) + telegram, err := NewTelegramChannel(m.config, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{ "error": err.Error(), @@ -149,6 +150,32 @@ func (m *Manager) initChannels() error { } } + if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" { + logger.DebugC("channels", "Attempting to initialize LINE channel") + line, err := NewLINEChannel(m.config.Channels.LINE, m.bus) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{ + "error": err.Error(), + }) + } else { + m.channels["line"] = line + logger.InfoC("channels", "LINE channel enabled successfully") + } + } + + if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" { + logger.DebugC("channels", "Attempting to initialize OneBot channel") + onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{ + "error": err.Error(), + }) + } else { + m.channels["onebot"] = onebot + logger.InfoC("channels", "OneBot channel enabled successfully") + } + } + logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{ "enabled_channels": len(m.channels), }) @@ -229,6 +256,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { continue } + // Silently skip internal channels + if constants.IsInternalChannel(msg.Channel) { + continue + } + m.mu.RLock() channel, exists := m.channels[msg.Channel] m.mu.RUnlock() diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go new file mode 100644 index 000000000..5d97fab9c --- /dev/null +++ b/pkg/channels/onebot.go @@ -0,0 +1,686 @@ +package channels + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type OneBotChannel struct { + *BaseChannel + config config.OneBotConfig + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + dedup map[string]struct{} + dedupRing []string + dedupIdx int + mu sync.Mutex + writeMu sync.Mutex + echoCounter int64 +} + +type oneBotRawEvent struct { + PostType string `json:"post_type"` + MessageType string `json:"message_type"` + SubType string `json:"sub_type"` + MessageID json.RawMessage `json:"message_id"` + UserID json.RawMessage `json:"user_id"` + GroupID json.RawMessage `json:"group_id"` + RawMessage string `json:"raw_message"` + Message json.RawMessage `json:"message"` + Sender json.RawMessage `json:"sender"` + SelfID json.RawMessage `json:"self_id"` + Time json.RawMessage `json:"time"` + MetaEventType string `json:"meta_event_type"` + Echo string `json:"echo"` + RetCode json.RawMessage `json:"retcode"` + Status BotStatus `json:"status"` +} + +type BotStatus struct { + Online bool `json:"online"` + Good bool `json:"good"` +} + +type oneBotSender struct { + UserID json.RawMessage `json:"user_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` +} + +type oneBotEvent struct { + PostType string + MessageType string + SubType string + MessageID string + UserID int64 + GroupID int64 + Content string + RawContent string + IsBotMentioned bool + Sender oneBotSender + SelfID int64 + Time int64 + MetaEventType string +} + +type oneBotAPIRequest struct { + Action string `json:"action"` + Params interface{} `json:"params"` + Echo string `json:"echo,omitempty"` +} + +type oneBotSendPrivateMsgParams struct { + UserID int64 `json:"user_id"` + Message string `json:"message"` +} + +type oneBotSendGroupMsgParams struct { + GroupID int64 `json:"group_id"` + Message string `json:"message"` +} + +func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { + base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom) + + const dedupSize = 1024 + return &OneBotChannel{ + BaseChannel: base, + config: cfg, + dedup: make(map[string]struct{}, dedupSize), + dedupRing: make([]string, dedupSize), + dedupIdx: 0, + }, nil +} + +func (c *OneBotChannel) Start(ctx context.Context) error { + if c.config.WSUrl == "" { + return fmt.Errorf("OneBot ws_url not configured") + } + + logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{ + "ws_url": c.config.WSUrl, + }) + + c.ctx, c.cancel = context.WithCancel(ctx) + + if err := c.connect(); err != nil { + logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{ + "error": err.Error(), + }) + } else { + go c.listen() + } + + if c.config.ReconnectInterval > 0 { + go c.reconnectLoop() + } else { + // If reconnect is disabled but initial connection failed, we cannot recover + if c.conn == nil { + return fmt.Errorf("failed to connect to OneBot and reconnect is disabled") + } + } + + c.setRunning(true) + logger.InfoC("onebot", "OneBot channel started successfully") + + return nil +} + +func (c *OneBotChannel) connect() error { + dialer := websocket.DefaultDialer + dialer.HandshakeTimeout = 10 * time.Second + + header := make(map[string][]string) + if c.config.AccessToken != "" { + header["Authorization"] = []string{"Bearer " + c.config.AccessToken} + } + + conn, _, err := dialer.Dial(c.config.WSUrl, header) + if err != nil { + return err + } + + c.mu.Lock() + c.conn = conn + c.mu.Unlock() + + logger.InfoC("onebot", "WebSocket connected") + return nil +} + +func (c *OneBotChannel) reconnectLoop() { + interval := time.Duration(c.config.ReconnectInterval) * time.Second + if interval < 5*time.Second { + interval = 5 * time.Second + } + + for { + select { + case <-c.ctx.Done(): + return + case <-time.After(interval): + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + logger.InfoC("onebot", "Attempting to reconnect...") + if err := c.connect(); err != nil { + logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{ + "error": err.Error(), + }) + } else { + go c.listen() + } + } + } + } +} + +func (c *OneBotChannel) Stop(ctx context.Context) error { + logger.InfoC("onebot", "Stopping OneBot channel") + c.setRunning(false) + + if c.cancel != nil { + c.cancel() + } + + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + + return nil +} + +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("OneBot channel not running") + } + + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return fmt.Errorf("OneBot WebSocket not connected") + } + + action, params, err := c.buildSendRequest(msg) + if err != nil { + return err + } + + c.writeMu.Lock() + c.echoCounter++ + echo := fmt.Sprintf("send_%d", c.echoCounter) + c.writeMu.Unlock() + + req := oneBotAPIRequest{ + Action: action, + Params: params, + Echo: echo, + } + + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal OneBot request: %w", err) + } + + c.writeMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + c.writeMu.Unlock() + + if err != nil { + logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{ + "error": err.Error(), + }) + return err + } + + return nil +} + +func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) { + chatID := msg.ChatID + + if len(chatID) > 6 && chatID[:6] == "group:" { + groupID, err := strconv.ParseInt(chatID[6:], 10, 64) + if err != nil { + return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID) + } + return "send_group_msg", oneBotSendGroupMsgParams{ + GroupID: groupID, + Message: msg.Content, + }, nil + } + + if len(chatID) > 8 && chatID[:8] == "private:" { + userID, err := strconv.ParseInt(chatID[8:], 10, 64) + if err != nil { + return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID) + } + return "send_private_msg", oneBotSendPrivateMsgParams{ + UserID: userID, + Message: msg.Content, + }, nil + } + + userID, err := strconv.ParseInt(chatID, 10, 64) + if err != nil { + return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID) + } + + return "send_private_msg", oneBotSendPrivateMsgParams{ + UserID: userID, + Message: msg.Content, + }, nil +} + +func (c *OneBotChannel) listen() { + for { + select { + case <-c.ctx.Done(): + return + default: + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + logger.WarnC("onebot", "WebSocket connection is nil, listener exiting") + return + } + + _, message, err := conn.ReadMessage() + if err != nil { + logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{ + "error": err.Error(), + }) + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + return + } + + logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{ + "length": len(message), + "payload": string(message), + }) + + var raw oneBotRawEvent + if err := json.Unmarshal(message, &raw); err != nil { + logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{ + "error": err.Error(), + "payload": string(message), + }) + continue + } + + if raw.Echo != "" || raw.Status.Online || raw.Status.Good { + logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{ + "echo": raw.Echo, + "status": raw.Status, + }) + continue + } + + logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{ + "post_type": raw.PostType, + "message_type": raw.MessageType, + "sub_type": raw.SubType, + "meta_event_type": raw.MetaEventType, + }) + + c.handleRawEvent(&raw) + } + } +} + +func parseJSONInt64(raw json.RawMessage) (int64, error) { + if len(raw) == 0 { + return 0, nil + } + + var n int64 + if err := json.Unmarshal(raw, &n); err == nil { + return n, nil + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strconv.ParseInt(s, 10, 64) + } + return 0, fmt.Errorf("cannot parse as int64: %s", string(raw)) +} + +func parseJSONString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + + return string(raw) +} + +type parseMessageResult struct { + Text string + IsBotMentioned bool +} + +func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult { + if len(raw) == 0 { + return parseMessageResult{} + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + mentioned := false + if selfID > 0 { + cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) + if strings.Contains(s, cqAt) { + mentioned = true + s = strings.ReplaceAll(s, cqAt, "") + s = strings.TrimSpace(s) + } + } + return parseMessageResult{Text: s, IsBotMentioned: mentioned} + } + + var segments []map[string]interface{} + if err := json.Unmarshal(raw, &segments); err == nil { + var text string + mentioned := false + selfIDStr := strconv.FormatInt(selfID, 10) + for _, seg := range segments { + segType, _ := seg["type"].(string) + data, _ := seg["data"].(map[string]interface{}) + switch segType { + case "text": + if data != nil { + if t, ok := data["text"].(string); ok { + text += t + } + } + case "at": + if data != nil && selfID > 0 { + qqVal := fmt.Sprintf("%v", data["qq"]) + if qqVal == selfIDStr || qqVal == "all" { + mentioned = true + } + } + } + } + return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned} + } + return parseMessageResult{} +} + +func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { + switch raw.PostType { + case "message": + evt, err := c.normalizeMessageEvent(raw) + if err != nil { + logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{ + "error": err.Error(), + }) + return + } + c.handleMessage(evt) + case "meta_event": + c.handleMetaEvent(raw) + case "notice": + logger.DebugCF("onebot", "Notice event received", map[string]interface{}{ + "sub_type": raw.SubType, + }) + case "request": + logger.DebugCF("onebot", "Request event received", map[string]interface{}{ + "sub_type": raw.SubType, + }) + case "": + logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{ + "echo": raw.Echo, + "status": raw.Status, + }) + default: + logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{ + "post_type": raw.PostType, + }) + } +} + +func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) { + userID, err := parseJSONInt64(raw.UserID) + if err != nil { + return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID)) + } + + groupID, _ := parseJSONInt64(raw.GroupID) + selfID, _ := parseJSONInt64(raw.SelfID) + ts, _ := parseJSONInt64(raw.Time) + messageID := parseJSONString(raw.MessageID) + + parsed := parseMessageContentEx(raw.Message, selfID) + isBotMentioned := parsed.IsBotMentioned + + content := raw.RawMessage + if content == "" { + content = parsed.Text + } else if selfID > 0 { + cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) + if strings.Contains(content, cqAt) { + isBotMentioned = true + content = strings.ReplaceAll(content, cqAt, "") + content = strings.TrimSpace(content) + } + } + + var sender oneBotSender + if len(raw.Sender) > 0 { + if err := json.Unmarshal(raw.Sender, &sender); err != nil { + logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{ + "error": err.Error(), + "sender": string(raw.Sender), + }) + } + } + + logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{ + "message_type": raw.MessageType, + "user_id": userID, + "group_id": groupID, + "message_id": messageID, + "content_len": len(content), + "nickname": sender.Nickname, + }) + + return &oneBotEvent{ + PostType: raw.PostType, + MessageType: raw.MessageType, + SubType: raw.SubType, + MessageID: messageID, + UserID: userID, + GroupID: groupID, + Content: content, + RawContent: raw.RawMessage, + IsBotMentioned: isBotMentioned, + Sender: sender, + SelfID: selfID, + Time: ts, + MetaEventType: raw.MetaEventType, + }, nil +} + +func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { + switch raw.MetaEventType { + case "lifecycle": + logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{ + "sub_type": raw.SubType, + }) + case "heartbeat": + logger.DebugC("onebot", "Heartbeat received") + default: + logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{ + "meta_event_type": raw.MetaEventType, + }) + } +} + +func (c *OneBotChannel) handleMessage(evt *oneBotEvent) { + if c.isDuplicate(evt.MessageID) { + logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{ + "message_id": evt.MessageID, + }) + return + } + + content := evt.Content + if content == "" { + logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{ + "message_id": evt.MessageID, + }) + return + } + + senderID := strconv.FormatInt(evt.UserID, 10) + var chatID string + + metadata := map[string]string{ + "message_id": evt.MessageID, + } + + switch evt.MessageType { + case "private": + chatID = "private:" + senderID + logger.InfoCF("onebot", "Received private message", map[string]interface{}{ + "sender": senderID, + "message_id": evt.MessageID, + "length": len(content), + "content": truncate(content, 100), + }) + + case "group": + groupIDStr := strconv.FormatInt(evt.GroupID, 10) + chatID = "group:" + groupIDStr + metadata["group_id"] = groupIDStr + + senderUserID, _ := parseJSONInt64(evt.Sender.UserID) + if senderUserID > 0 { + metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10) + } + + if evt.Sender.Card != "" { + metadata["sender_name"] = evt.Sender.Card + } else if evt.Sender.Nickname != "" { + metadata["sender_name"] = evt.Sender.Nickname + } + + triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned) + if !triggered { + logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{ + "sender": senderID, + "group": groupIDStr, + "is_mentioned": evt.IsBotMentioned, + "content": truncate(content, 100), + }) + return + } + content = strippedContent + + logger.InfoCF("onebot", "Received group message", map[string]interface{}{ + "sender": senderID, + "group": groupIDStr, + "message_id": evt.MessageID, + "is_mentioned": evt.IsBotMentioned, + "length": len(content), + "content": truncate(content, 100), + }) + + default: + logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{ + "type": evt.MessageType, + "message_id": evt.MessageID, + "user_id": evt.UserID, + }) + return + } + + if evt.Sender.Nickname != "" { + metadata["nickname"] = evt.Sender.Nickname + } + + logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{ + "sender_id": senderID, + "chat_id": chatID, + "content": truncate(content, 100), + }) + + c.HandleMessage(senderID, chatID, content, []string{}, metadata) +} + +func (c *OneBotChannel) isDuplicate(messageID string) bool { + if messageID == "" || messageID == "0" { + return false + } + + c.mu.Lock() + defer c.mu.Unlock() + + if _, exists := c.dedup[messageID]; exists { + return true + } + + if old := c.dedupRing[c.dedupIdx]; old != "" { + delete(c.dedup, old) + } + c.dedupRing[c.dedupIdx] = messageID + c.dedup[messageID] = struct{}{} + c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing) + + return false +} + +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} + +func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) { + if isBotMentioned { + return true, strings.TrimSpace(content) + } + + for _, prefix := range c.config.GroupTriggerPrefix { + if prefix == "" { + continue + } + if strings.HasPrefix(content, prefix) { + return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) + } + } + + return false, content +} diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index 6c02ec09c..4d3392409 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -25,6 +25,7 @@ type SlackChannel struct { api *slack.Client socketClient *socketmode.Client botUserID string + teamID string transcriber *voice.GroqTranscriber ctx context.Context cancel context.CancelFunc @@ -72,6 +73,7 @@ func (c *SlackChannel) Start(ctx context.Context) error { return fmt.Errorf("slack auth test failed: %w", err) } c.botUserID = authResp.UserID + c.teamID = authResp.TeamID logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{ "bot_user_id": c.botUserID, @@ -274,11 +276,21 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { return } + peerKind := "channel" + peerID := channelID + if strings.HasPrefix(channelID, "D") { + peerKind = "direct" + peerID = senderID + } + metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", + "peer_kind": peerKind, + "peer_id": peerID, + "team_id": c.teamID, } logger.DebugCF("slack", "Received message", map[string]interface{}{ @@ -302,6 +314,13 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { return } + if !c.IsAllowed(ev.User) { + logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{ + "user_id": ev.User, + }) + return + } + senderID := ev.User channelID := ev.Channel threadTS := ev.ThreadTimeStamp @@ -330,12 +349,22 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { return } + mentionPeerKind := "channel" + mentionPeerID := channelID + if strings.HasPrefix(channelID, "D") { + mentionPeerKind = "direct" + mentionPeerID = senderID + } + metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", "is_mention": "true", + "peer_kind": mentionPeerKind, + "peer_id": mentionPeerID, + "team_id": c.teamID, } c.HandleMessage(senderID, chatID, content, nil, metadata) @@ -351,6 +380,13 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { c.socketClient.Ack(*event.Request) } + if !c.IsAllowed(cmd.UserID) { + logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{ + "user_id": cmd.UserID, + }) + return + } + senderID := cmd.UserID if !c.IsAllowed(senderID) { logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{ @@ -373,6 +409,9 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "platform": "slack", "is_command": "true", "trigger_id": cmd.TriggerID, + "peer_kind": "channel", + "peer_id": channelID, + "team_id": c.teamID, } logger.DebugCF("slack", "Slash command received", map[string]interface{}{ diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 95f6102cd..24b82b557 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -3,13 +3,18 @@ package channels import ( "context" "fmt" + "net/http" + "net/url" "os" "regexp" "strings" "sync" "time" + th "github.com/mymmrac/telego/telegohandler" + "github.com/mymmrac/telego" + "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" @@ -22,7 +27,8 @@ import ( type TelegramChannel struct { *BaseChannel bot *telego.Bot - config config.TelegramConfig + commands TelegramCommander + config *config.Config chatIDs map[string]int64 transcriber *voice.GroqTranscriber placeholders sync.Map // chatID -> messageID @@ -39,16 +45,32 @@ func (c *thinkingCancel) Cancel() { } } -func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) { - bot, err := telego.NewBot(cfg.Token) +func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { + var opts []telego.BotOption + telegramCfg := cfg.Channels.Telegram + + if telegramCfg.Proxy != "" { + proxyURL, parseErr := url.Parse(telegramCfg.Proxy) + if parseErr != nil { + return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr) + } + opts = append(opts, telego.WithHTTPClient(&http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + })) + } + + bot, err := telego.NewBot(telegramCfg.Token, opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } - base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom) + base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom) return &TelegramChannel{ BaseChannel: base, + commands: NewTelegramCommands(bot, cfg), bot: bot, config: cfg, chatIDs: make(map[string]int64), @@ -72,31 +94,45 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to start long polling: %w", err) } + bh, err := telegohandler.NewBotHandler(c.bot, updates) + if err != nil { + return fmt.Errorf("failed to create bot handler: %w", err) + } + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + c.commands.Help(ctx, message) + return nil + }, th.CommandEqual("help")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Start(ctx, message) + }, th.CommandEqual("start")) + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Show(ctx, message) + }, th.CommandEqual("show")) + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.List(ctx, message) + }, th.CommandEqual("list")) + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.handleMessage(ctx, &message) + }, th.AnyMessage()) + c.setRunning(true) logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{ "username": c.bot.Username(), }) + go bh.Start() + go func() { - for { - select { - case <-ctx.Done(): - return - case update, ok := <-updates: - if !ok { - logger.InfoC("telegram", "Updates channel closed, reconnecting...") - return - } - if update.Message != nil { - c.handleMessage(ctx, update) - } - } - } + <-ctx.Done() + bh.Stop() }() return nil } - func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.setRunning(false) @@ -150,15 +186,14 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } -func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Update) { - message := update.Message +func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { if message == nil { - return + return fmt.Errorf("message is nil") } user := message.From if user == nil { - return + return fmt.Errorf("message sender (user) is nil") } senderID := fmt.Sprintf("%d", user.ID) @@ -171,7 +206,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{ "user_id": senderID, }) - return + return nil } chatID := message.Chat.ID @@ -204,7 +239,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat content += message.Caption } - if message.Photo != nil && len(message.Photo) > 0 { + if len(message.Photo) > 0 { photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { @@ -213,7 +248,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat if content != "" { content += "\n" } - content += fmt.Sprintf("[image: photo]") + content += "[image: photo]" } } @@ -234,7 +269,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat "error": err.Error(), "path": voicePath, }) - transcribedText = fmt.Sprintf("[voice (transcription failed)]") + transcribedText = "[voice (transcription failed)]" } else { transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{ @@ -242,7 +277,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat }) } } else { - transcribedText = fmt.Sprintf("[voice]") + transcribedText = "[voice]" } if content != "" { @@ -260,7 +295,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat if content != "" { content += "\n" } - content += fmt.Sprintf("[audio]") + content += "[audio]" } } @@ -272,7 +307,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat if content != "" { content += "\n" } - content += fmt.Sprintf("[file]") + content += "[file]" } } @@ -302,37 +337,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } } - // Create new context for thinking animation with timeout - thinkCtx, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) + // Create cancel function for thinking state + _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) if err == nil { pID := pMsg.MessageID c.placeholders.Store(chatIDStr, pID) + } - go func(cid int64, mid int) { - dots := []string{".", "..", "..."} - emotes := []string{"💭", "🤔", "☁️"} - i := 0 - ticker := time.NewTicker(2000 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-thinkCtx.Done(): - return - case <-ticker.C: - i++ - text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)]) - _, editErr := c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text)) - if editErr != nil { - logger.DebugCF("telegram", "Failed to edit thinking message", map[string]interface{}{ - "error": editErr.Error(), - }) - } - } - } - }(chatID, pID) + peerKind := "direct" + peerID := fmt.Sprintf("%d", user.ID) + if message.Chat.Type != "private" { + peerKind = "group" + peerID = fmt.Sprintf("%d", chatID) } metadata := map[string]string{ @@ -341,9 +360,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat "username": user.Username, "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), + "peer_kind": peerKind, + "peer_id": peerID, } - c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + return nil } func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { @@ -454,8 +476,11 @@ func extractCodeBlocks(text string) codeBlockMatch { codes = append(codes, match[1]) } + i := 0 text = re.ReplaceAllStringFunc(text, func(m string) string { - return fmt.Sprintf("\x00CB%d\x00", len(codes)-1) + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder }) return codeBlockMatch{text: text, codes: codes} @@ -475,8 +500,11 @@ func extractInlineCodes(text string) inlineCodeMatch { codes = append(codes, match[1]) } + i := 0 text = re.ReplaceAllStringFunc(text, func(m string) string { - return fmt.Sprintf("\x00IC%d\x00", len(codes)-1) + placeholder := fmt.Sprintf("\x00IC%d\x00", i) + i++ + return placeholder }) return inlineCodeMatch{text: text, codes: codes} diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go new file mode 100644 index 000000000..df245e156 --- /dev/null +++ b/pkg/channels/telegram_commands.go @@ -0,0 +1,153 @@ +package channels + +import ( + "context" + "fmt" + "strings" + + "github.com/mymmrac/telego" + "github.com/sipeed/picoclaw/pkg/config" +) + +type TelegramCommander interface { + Help(ctx context.Context, message telego.Message) error + Start(ctx context.Context, message telego.Message) error + Show(ctx context.Context, message telego.Message) error + List(ctx context.Context, message telego.Message) error +} + +type cmd struct { + bot *telego.Bot + config *config.Config +} + +func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander { + return &cmd{ + bot: bot, + config: cfg, + } +} + +func commandArgs(text string) string { + parts := strings.SplitN(text, " ", 2) + if len(parts) < 2 { + return "" + } + return strings.TrimSpace(parts[1]) +} +func (c *cmd) Help(ctx context.Context, message telego.Message) error { + msg := `/start - Start the bot +/help - Show this help message +/show [model|channel] - Show current configuration +/list [models|channels] - List available options + ` + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: msg, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} + +func (c *cmd) Start(ctx context.Context, message telego.Message) error { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Hello! I am PicoClaw 🦞", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} + +func (c *cmd) Show(ctx context.Context, message telego.Message) error { + args := commandArgs(message.Text) + if args == "" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /show [model|channel]", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + var response string + switch args { + case "model": + response = fmt.Sprintf("Current Model: %s (Provider: %s)", + c.config.Agents.Defaults.Model, + c.config.Agents.Defaults.Provider) + case "channel": + response = "Current Channel: telegram" + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) + } + + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: response, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} +func (c *cmd) List(ctx context.Context, message telego.Message) error { + args := commandArgs(message.Text) + if args == "" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /list [models|channels]", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + var response string + switch args { + case "models": + provider := c.config.Agents.Defaults.Provider + if provider == "" { + provider = "configured default" + } + response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", + c.config.Agents.Defaults.Model, provider) + + case "channels": + var enabled []string + if c.config.Channels.Telegram.Enabled { + enabled = append(enabled, "telegram") + } + if c.config.Channels.WhatsApp.Enabled { + enabled = append(enabled, "whatsapp") + } + if c.config.Channels.Feishu.Enabled { + enabled = append(enabled, "feishu") + } + if c.config.Channels.Discord.Enabled { + enabled = append(enabled, "discord") + } + if c.config.Channels.Slack.Enabled { + enabled = append(enabled, "slack") + } + response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")) + + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) + } + + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: response, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 45f05b84b..6332e8e0b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "fmt" "os" "path/filepath" "sync" @@ -10,25 +11,145 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +// FlexibleStringSlice is a []string that also accepts JSON numbers, +// so allow_from can contain both "123" and 123. +type FlexibleStringSlice []string + +func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + // Try []string first + var ss []string + if err := json.Unmarshal(data, &ss); err == nil { + *f = ss + return nil + } + + // Try []interface{} to handle mixed types + var raw []interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + result := make([]string, 0, len(raw)) + for _, v := range raw { + switch val := v.(type) { + case string: + result = append(result, val) + case float64: + result = append(result, fmt.Sprintf("%.0f", val)) + default: + result = append(result, fmt.Sprintf("%v", val)) + } + } + *f = result + return nil +} + type Config struct { Agents AgentsConfig `json:"agents"` + Bindings []AgentBinding `json:"bindings,omitempty"` + Session SessionConfig `json:"session,omitempty"` Channels ChannelsConfig `json:"channels"` Providers ProvidersConfig `json:"providers"` Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` + Heartbeat HeartbeatConfig `json:"heartbeat"` + Devices DevicesConfig `json:"devices"` mu sync.RWMutex } type AgentsConfig struct { Defaults AgentDefaults `json:"defaults"` + List []AgentConfig `json:"list,omitempty"` +} + +// AgentModelConfig supports both string and structured model config. +// String format: "gpt-4" (just primary, no fallbacks) +// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]} +type AgentModelConfig struct { + Primary string `json:"primary,omitempty"` + Fallbacks []string `json:"fallbacks,omitempty"` +} + +func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err == nil { + m.Primary = s + m.Fallbacks = nil + return nil + } + type raw struct { + Primary string `json:"primary"` + Fallbacks []string `json:"fallbacks"` + } + var r raw + if err := json.Unmarshal(data, &r); err != nil { + return err + } + m.Primary = r.Primary + m.Fallbacks = r.Fallbacks + return nil +} + +func (m AgentModelConfig) MarshalJSON() ([]byte, error) { + if len(m.Fallbacks) == 0 && m.Primary != "" { + return json.Marshal(m.Primary) + } + type raw struct { + Primary string `json:"primary,omitempty"` + Fallbacks []string `json:"fallbacks,omitempty"` + } + return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) +} + +type AgentConfig struct { + ID string `json:"id"` + Default bool `json:"default,omitempty"` + Name string `json:"name,omitempty"` + Workspace string `json:"workspace,omitempty"` + Model *AgentModelConfig `json:"model,omitempty"` + Skills []string `json:"skills,omitempty"` + Subagents *SubagentsConfig `json:"subagents,omitempty"` +} + +type SubagentsConfig struct { + AllowAgents []string `json:"allow_agents,omitempty"` + Model *AgentModelConfig `json:"model,omitempty"` +} + +type PeerMatch struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +type BindingMatch struct { + Channel string `json:"channel"` + AccountID string `json:"account_id,omitempty"` + Peer *PeerMatch `json:"peer,omitempty"` + GuildID string `json:"guild_id,omitempty"` + TeamID string `json:"team_id,omitempty"` +} + +type AgentBinding struct { + AgentID string `json:"agent_id"` + Match BindingMatch `json:"match"` +} + +type SessionConfig struct { + DMScope string `json:"dm_scope,omitempty"` + IdentityLinks map[string][]string `json:"identity_links,omitempty"` } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` } type ChannelsConfig struct { @@ -40,77 +161,122 @@ type ChannelsConfig struct { QQ QQConfig `json:"qq"` DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` + LINE LINEConfig `json:"line"` + OneBot OneBotConfig `json:"onebot"` } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` - AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` +} + +type LINEConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` +} + +type OneBotConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` +} + +type HeartbeatConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` + Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 +} + +type DevicesConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` + MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI OpenAIProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Zhipu ProviderConfig `json:"zhipu"` + VLLM ProviderConfig `json:"vllm"` + Gemini ProviderConfig `json:"gemini"` + Nvidia ProviderConfig `json:"nvidia"` + Ollama ProviderConfig `json:"ollama"` + Moonshot ProviderConfig `json:"moonshot"` + ShengSuanYun ProviderConfig `json:"shengsuanyun"` + DeepSeek ProviderConfig `json:"deepseek"` + GitHubCopilot ProviderConfig `json:"github_copilot"` } type ProviderConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` - AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` + APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` + AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` + ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` +} + +type OpenAIProviderConfig struct { + ProviderConfig + WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` } type GatewayConfig struct { @@ -118,40 +284,67 @@ type GatewayConfig struct { Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } -type WebSearchConfig struct { - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"` +type BraveConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` +} + +type DuckDuckGoConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` +} + +type PerplexityConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } type WebToolsConfig struct { - Search WebSearchConfig `json:"search"` + Brave BraveConfig `json:"brave"` + DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` + Perplexity PerplexityConfig `json:"perplexity"` +} + +type CronToolsConfig struct { + ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout +} + +type ExecConfig struct { + EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` + CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` } type ToolsConfig struct { - Web WebToolsConfig `json:"web"` + Web WebToolsConfig `json:"web"` + Cron CronToolsConfig `json:"cron"` + Exec ExecConfig `json:"exec"` } func DefaultConfig() *Config { return &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ - Workspace: "~/.picoclaw/workspace", - Model: "openrouter/auto", - MaxTokens: 8192, - Temperature: 0.7, - MaxToolIterations: 20, + Workspace: "~/.picoclaw/workspace", + RestrictToWorkspace: true, + Provider: "", + Model: "openrouter/auto", + MaxTokens: 8192, + Temperature: 0.7, + MaxToolIterations: 20, }, }, Channels: ChannelsConfig{ WhatsApp: WhatsAppConfig{ Enabled: false, BridgeURL: "ws://localhost:3001", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ Enabled: false, Token: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Feishu: FeishuConfig{ Enabled: false, @@ -159,46 +352,66 @@ func DefaultConfig() *Config { AppSecret: "", EncryptKey: "", VerificationToken: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Discord: DiscordConfig{ Enabled: false, Token: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, MaixCam: MaixCamConfig{ Enabled: false, Host: "0.0.0.0", Port: 18790, - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ Enabled: false, AppID: "", AppSecret: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, DingTalk: DingTalkConfig{ Enabled: false, ClientID: "", ClientSecret: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, }, Slack: SlackConfig{ Enabled: false, BotToken: "", AppToken: "", - AllowFrom: []string{}, + AllowFrom: FlexibleStringSlice{}, + }, + LINE: LINEConfig{ + Enabled: false, + ChannelSecret: "", + ChannelAccessToken: "", + WebhookHost: "0.0.0.0", + WebhookPort: 18791, + WebhookPath: "/webhook/line", + AllowFrom: FlexibleStringSlice{}, + }, + OneBot: OneBotConfig{ + Enabled: false, + WSUrl: "ws://127.0.0.1:3001", + AccessToken: "", + ReconnectInterval: 5, + GroupTriggerPrefix: []string{}, + AllowFrom: FlexibleStringSlice{}, }, }, Providers: ProvidersConfig{ - Anthropic: ProviderConfig{}, - OpenAI: ProviderConfig{}, - OpenRouter: ProviderConfig{}, - Groq: ProviderConfig{}, - Zhipu: ProviderConfig{}, - VLLM: ProviderConfig{}, - Gemini: ProviderConfig{}, + Anthropic: ProviderConfig{}, + OpenAI: OpenAIProviderConfig{WebSearch: true}, + OpenRouter: ProviderConfig{}, + Groq: ProviderConfig{}, + Zhipu: ProviderConfig{}, + VLLM: ProviderConfig{}, + Gemini: ProviderConfig{}, + Nvidia: ProviderConfig{}, + Moonshot: ProviderConfig{}, + ShengSuanYun: ProviderConfig{}, }, Gateway: GatewayConfig{ Host: "0.0.0.0", @@ -206,11 +419,35 @@ func DefaultConfig() *Config { }, Tools: ToolsConfig{ Web: WebToolsConfig{ - Search: WebSearchConfig{ + Brave: BraveConfig{ + Enabled: false, + APIKey: "", + MaxResults: 5, + }, + DuckDuckGo: DuckDuckGoConfig{ + Enabled: true, + MaxResults: 5, + }, + Perplexity: PerplexityConfig{ + Enabled: false, APIKey: "", MaxResults: 5, }, }, + Cron: CronToolsConfig{ + ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations + }, + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, + }, + Heartbeat: HeartbeatConfig{ + Enabled: true, + Interval: 30, // default 30 minutes + }, + Devices: DevicesConfig{ + Enabled: false, + MonitorUSB: true, }, } } @@ -286,6 +523,9 @@ func (c *Config) GetAPIKey() string { if c.Providers.VLLM.APIKey != "" { return c.Providers.VLLM.APIKey } + if c.Providers.ShengSuanYun.APIKey != "" { + return c.Providers.ShengSuanYun.APIKey + } return "" } @@ -307,6 +547,32 @@ func (c *Config) GetAPIBase() string { return "" } +// ModelConfig holds primary model and fallback list. +type ModelConfig struct { + Primary string + Fallbacks []string +} + +// GetModelConfig returns the text model configuration with fallbacks. +func (c *Config) GetModelConfig() ModelConfig { + c.mu.RLock() + defer c.mu.RUnlock() + return ModelConfig{ + Primary: c.Agents.Defaults.Model, + Fallbacks: c.Agents.Defaults.ModelFallbacks, + } +} + +// GetImageModelConfig returns the image model configuration with fallbacks. +func (c *Config) GetImageModelConfig() ModelConfig { + c.mu.RLock() + defer c.mu.RUnlock() + return ModelConfig{ + Primary: c.Agents.Defaults.ImageModel, + Fallbacks: c.Agents.Defaults.ImageModelFallbacks, + } +} + func expandHome(path string) string { if path == "" { return path diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index e6e17cbb4..ee3017093 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,44 +1,403 @@ package config import ( + "encoding/json" "os" "path/filepath" "runtime" "testing" ) -func TestDefaultConfigUsesOpenRouterAutoModel(t *testing.T) { - cfg := DefaultConfig() - - if cfg.Agents.Defaults.Model != "openrouter/auto" { - t.Fatalf("default model = %q, want %q", cfg.Agents.Defaults.Model, "openrouter/auto") +func TestAgentModelConfig_UnmarshalString(t *testing.T) { + var m AgentModelConfig + if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil { + t.Fatalf("unmarshal string: %v", err) + } + if m.Primary != "gpt-4" { + t.Errorf("Primary = %q, want 'gpt-4'", m.Primary) + } + if m.Fallbacks != nil { + t.Errorf("Fallbacks = %v, want nil", m.Fallbacks) } } -func TestSaveConfigWritesPrivatePermissions(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("file permission bits are not reliable on windows") +func TestAgentModelConfig_UnmarshalObject(t *testing.T) { + var m AgentModelConfig + data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}` + if err := json.Unmarshal([]byte(data), &m); err != nil { + t.Fatalf("unmarshal object: %v", err) } + if m.Primary != "claude-opus" { + t.Errorf("Primary = %q, want 'claude-opus'", m.Primary) + } + if len(m.Fallbacks) != 2 { + t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks)) + } + if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" { + t.Errorf("Fallbacks = %v", m.Fallbacks) + } +} - path := filepath.Join(t.TempDir(), "config.json") - if err := os.WriteFile(path, []byte("{}"), 0644); err != nil { - t.Fatalf("failed to create seed config: %v", err) +func TestAgentModelConfig_MarshalString(t *testing.T) { + m := AgentModelConfig{Primary: "gpt-4"} + data, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) } + if string(data) != `"gpt-4"` { + t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data)) + } +} + +func TestAgentModelConfig_MarshalObject(t *testing.T) { + m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}} + data, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var result map[string]interface{} + json.Unmarshal(data, &result) + if result["primary"] != "claude-opus" { + t.Errorf("primary = %v", result["primary"]) + } +} + +func TestAgentConfig_FullParse(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "max_tool_iterations": 20 + }, + "list": [ + { + "id": "sales", + "default": true, + "name": "Sales Bot", + "model": "gpt-4" + }, + { + "id": "support", + "name": "Support Bot", + "model": { + "primary": "claude-opus", + "fallbacks": ["haiku"] + }, + "subagents": { + "allow_agents": ["sales"] + } + } + ] + }, + "bindings": [ + { + "agent_id": "support", + "match": { + "channel": "telegram", + "account_id": "*", + "peer": {"kind": "direct", "id": "user123"} + } + } + ], + "session": { + "dm_scope": "per-peer", + "identity_links": { + "john": ["telegram:123", "discord:john#1234"] + } + } + }` cfg := DefaultConfig() - cfg.Providers.OpenRouter.APIKey = "secret" + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(cfg.Agents.List) != 2 { + t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List)) + } + + sales := cfg.Agents.List[0] + if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" { + t.Errorf("sales = %+v", sales) + } + if sales.Model == nil || sales.Model.Primary != "gpt-4" { + t.Errorf("sales.Model = %+v", sales.Model) + } + + support := cfg.Agents.List[1] + if support.ID != "support" || support.Name != "Support Bot" { + t.Errorf("support = %+v", support) + } + if support.Model == nil || support.Model.Primary != "claude-opus" { + t.Errorf("support.Model = %+v", support.Model) + } + if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" { + t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) + } + if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 { + t.Errorf("support.Subagents = %+v", support.Subagents) + } + + if len(cfg.Bindings) != 1 { + t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings)) + } + binding := cfg.Bindings[0] + if binding.AgentID != "support" || binding.Match.Channel != "telegram" { + t.Errorf("binding = %+v", binding) + } + if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" { + t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer) + } + + if cfg.Session.DMScope != "per-peer" { + t.Errorf("Session.DMScope = %q", cfg.Session.DMScope) + } + if len(cfg.Session.IdentityLinks) != 1 { + t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks) + } + links := cfg.Session.IdentityLinks["john"] + if len(links) != 2 { + t.Errorf("john links = %v", links) + } +} + +func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "max_tool_iterations": 20 + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(cfg.Agents.List) != 0 { + t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List)) + } + if len(cfg.Bindings) != 0 { + t.Errorf("bindings should be empty, got %d", len(cfg.Bindings)) + } +} + +// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default +func TestDefaultConfig_HeartbeatEnabled(t *testing.T) { + cfg := DefaultConfig() + + if !cfg.Heartbeat.Enabled { + t.Error("Heartbeat should be enabled by default") + } +} + +// TestDefaultConfig_WorkspacePath verifies workspace path is correctly set +func TestDefaultConfig_WorkspacePath(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.Workspace == "" { + t.Error("Workspace should not be empty") + } +} + +// TestDefaultConfig_Model verifies model is set +func TestDefaultConfig_Model(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.Model == "" { + t.Error("Model should not be empty") + } +} + +// TestDefaultConfig_MaxTokens verifies max tokens has default value +func TestDefaultConfig_MaxTokens(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.MaxTokens == 0 { + t.Error("MaxTokens should not be zero") + } +} + +// TestDefaultConfig_MaxToolIterations verifies max tool iterations has default value +func TestDefaultConfig_MaxToolIterations(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.MaxToolIterations == 0 { + t.Error("MaxToolIterations should not be zero") + } +} + +// TestDefaultConfig_Temperature verifies temperature has default value +func TestDefaultConfig_Temperature(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.Temperature == 0 { + t.Error("Temperature should not be zero") + } +} + +// TestDefaultConfig_Gateway verifies gateway defaults +func TestDefaultConfig_Gateway(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Gateway.Host != "0.0.0.0" { + t.Error("Gateway host should have default value") + } + if cfg.Gateway.Port == 0 { + t.Error("Gateway port should have default value") + } +} + +// TestDefaultConfig_Providers verifies provider structure +func TestDefaultConfig_Providers(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Providers.Anthropic.APIKey != "" { + t.Error("Anthropic API key should be empty by default") + } + if cfg.Providers.OpenAI.APIKey != "" { + t.Error("OpenAI API key should be empty by default") + } + if cfg.Providers.OpenRouter.APIKey != "" { + t.Error("OpenRouter API key should be empty by default") + } +} + +// TestDefaultConfig_Channels verifies channels are disabled by default +func TestDefaultConfig_Channels(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Channels.Telegram.Enabled { + t.Error("Telegram should be disabled by default") + } + if cfg.Channels.Discord.Enabled { + t.Error("Discord should be disabled by default") + } + if cfg.Channels.Slack.Enabled { + t.Error("Slack should be disabled by default") + } +} + +// TestDefaultConfig_WebTools verifies web tools config +func TestDefaultConfig_WebTools(t *testing.T) { + cfg := DefaultConfig() + + // Verify web tools defaults + if cfg.Tools.Web.Brave.MaxResults != 5 { + t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) + } + if cfg.Tools.Web.Brave.APIKey != "" { + t.Error("Brave API key should be empty by default") + } + if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { + t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults) + } +} + +func TestSaveConfig_FilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permission bits are not enforced on Windows") + } + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.json") + + cfg := DefaultConfig() if err := SaveConfig(path, cfg); err != nil { t.Fatalf("SaveConfig failed: %v", err) } info, err := os.Stat(path) if err != nil { - t.Fatalf("stat config: %v", err) + t.Fatalf("Stat failed: %v", err) } - if got := info.Mode().Perm(); got != 0600 { - t.Fatalf("config perms = %o, want 600", got) + perm := info.Mode().Perm() + if perm != 0600 { + t.Errorf("config file has permission %04o, want 0600", perm) + } +} + +// TestConfig_Complete verifies all config fields are set +func TestConfig_Complete(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.Workspace == "" { + t.Error("Workspace should not be empty") + } + if cfg.Agents.Defaults.Model == "" { + t.Error("Model should not be empty") + } + if cfg.Agents.Defaults.Temperature == 0 { + t.Error("Temperature should have default value") + } + if cfg.Agents.Defaults.MaxTokens == 0 { + t.Error("MaxTokens should not be zero") + } + if cfg.Agents.Defaults.MaxToolIterations == 0 { + t.Error("MaxToolIterations should not be zero") + } + if cfg.Gateway.Host != "0.0.0.0" { + t.Error("Gateway host should have default value") + } + if cfg.Gateway.Port == 0 { + t.Error("Gateway port should have default value") + } + if !cfg.Heartbeat.Enabled { + t.Error("Heartbeat should be enabled by default") + } +} + +func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") + } +} + +func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should remain true when unset in config file") + } +} + +func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should be false when disabled in config file") + } +} + +func TestDefaultConfigUsesOpenRouterAutoModel(t *testing.T) { + cfg := DefaultConfig() + + if cfg.Agents.Defaults.Model != "openrouter/auto" { + t.Fatalf("default model = %q, want %q", cfg.Agents.Defaults.Model, "openrouter/auto") } } @@ -49,7 +408,7 @@ func TestLoadConfigNormalizesLegacyGLMModelForOpenRouter(t *testing.T) { "providers": { "openrouter": { "api_key": "sk-or-test" } } }` - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write config fixture: %v", err) } @@ -73,7 +432,7 @@ func TestLoadConfigKeepsLegacyGLMModelWhenZhipuConfigured(t *testing.T) { } }` - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write config fixture: %v", err) } @@ -93,7 +452,7 @@ func TestLoadConfigKeepsLegacyGLMModelWhenNoProviderKeysConfigured(t *testing.T) "agents": { "defaults": { "model": "glm-4.7" } } }` - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write config fixture: %v", err) } diff --git a/pkg/constants/channels.go b/pkg/constants/channels.go new file mode 100644 index 000000000..3e3df3839 --- /dev/null +++ b/pkg/constants/channels.go @@ -0,0 +1,15 @@ +// Package constants provides shared constants across the codebase. +package constants + +// InternalChannels defines channels that are used for internal communication +// and should not be exposed to external users or recorded as last active channel. +var InternalChannels = map[string]bool{ + "cli": true, + "system": true, + "subagent": true, +} + +// IsInternalChannel returns true if the channel is an internal channel. +func IsInternalChannel(channel string) bool { + return InternalChannels[channel] +} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index b41f11826..d9963531b 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -26,6 +26,7 @@ type CronSchedule struct { type CronPayload struct { Kind string `json:"kind"` Message string `json:"message"` + Command string `json:"command,omitempty"` Deliver bool `json:"deliver"` Channel string `json:"channel,omitempty"` To string `json:"to,omitempty"` @@ -71,7 +72,6 @@ func NewCronService(storePath string, onJob JobHandler) *CronService { cs := &CronService{ storePath: storePath, onJob: onJob, - stopChan: make(chan struct{}), gronx: gronx.New(), } // Initialize and load store on creation @@ -96,8 +96,9 @@ func (cs *CronService) Start() error { return fmt.Errorf("failed to save store: %w", err) } + cs.stopChan = make(chan struct{}) cs.running = true - go cs.runLoop() + go cs.runLoop(cs.stopChan) return nil } @@ -111,16 +112,19 @@ func (cs *CronService) Stop() { } cs.running = false - close(cs.stopChan) + if cs.stopChan != nil { + close(cs.stopChan) + cs.stopChan = nil + } } -func (cs *CronService) runLoop() { +func (cs *CronService) runLoop(stopChan chan struct{}) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { - case <-cs.stopChan: + case <-stopChan: return case <-ticker.C: cs.checkJobs() @@ -137,27 +141,23 @@ func (cs *CronService) checkJobs() { } now := time.Now().UnixMilli() - var dueJobs []*CronJob + var dueJobIDs []string // Collect jobs that are due (we need to copy them to execute outside lock) for i := range cs.store.Jobs { job := &cs.store.Jobs[i] if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now { - // Create a shallow copy of the job for execution - jobCopy := *job - dueJobs = append(dueJobs, &jobCopy) + dueJobIDs = append(dueJobIDs, job.ID) } } - // Update next run times for due jobs immediately (before executing) - // Use map for O(n) lookup instead of O(n²) nested loop - dueMap := make(map[string]bool, len(dueJobs)) - for _, job := range dueJobs { - dueMap[job.ID] = true + // Reset next run for due jobs before unlocking to avoid duplicate execution. + dueMap := make(map[string]bool, len(dueJobIDs)) + for _, jobID := range dueJobIDs { + dueMap[jobID] = true } for i := range cs.store.Jobs { if dueMap[cs.store.Jobs[i].ID] { - // Reset NextRunAtMS temporarily so we don't re-execute cs.store.Jobs[i].State.NextRunAtMS = nil } } @@ -168,53 +168,75 @@ func (cs *CronService) checkJobs() { cs.mu.Unlock() - // Execute jobs outside the lock - for _, job := range dueJobs { - cs.executeJob(job) + // Execute jobs outside lock. + for _, jobID := range dueJobIDs { + cs.executeJobByID(jobID) } } -func (cs *CronService) executeJob(job *CronJob) { +func (cs *CronService) executeJobByID(jobID string) { startTime := time.Now().UnixMilli() + cs.mu.RLock() + var callbackJob *CronJob + for i := range cs.store.Jobs { + job := &cs.store.Jobs[i] + if job.ID == jobID { + jobCopy := *job + callbackJob = &jobCopy + break + } + } + cs.mu.RUnlock() + + if callbackJob == nil { + return + } + var err error if cs.onJob != nil { - _, err = cs.onJob(job) + _, err = cs.onJob(callbackJob) } // Now acquire lock to update state cs.mu.Lock() defer cs.mu.Unlock() - // Find the job in store and update it + var job *CronJob for i := range cs.store.Jobs { - if cs.store.Jobs[i].ID == job.ID { - cs.store.Jobs[i].State.LastRunAtMS = &startTime - cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli() - - if err != nil { - cs.store.Jobs[i].State.LastStatus = "error" - cs.store.Jobs[i].State.LastError = err.Error() - } else { - cs.store.Jobs[i].State.LastStatus = "ok" - cs.store.Jobs[i].State.LastError = "" - } - - // Compute next run time - if cs.store.Jobs[i].Schedule.Kind == "at" { - if cs.store.Jobs[i].DeleteAfterRun { - cs.removeJobUnsafe(job.ID) - } else { - cs.store.Jobs[i].Enabled = false - cs.store.Jobs[i].State.NextRunAtMS = nil - } - } else { - nextRun := cs.computeNextRun(&cs.store.Jobs[i].Schedule, time.Now().UnixMilli()) - cs.store.Jobs[i].State.NextRunAtMS = nextRun - } + if cs.store.Jobs[i].ID == jobID { + job = &cs.store.Jobs[i] break } } + if job == nil { + log.Printf("[cron] job %s disappeared before state update", jobID) + return + } + + job.State.LastRunAtMS = &startTime + job.UpdatedAtMS = time.Now().UnixMilli() + + if err != nil { + job.State.LastStatus = "error" + job.State.LastError = err.Error() + } else { + job.State.LastStatus = "ok" + job.State.LastError = "" + } + + // Compute next run time + if job.Schedule.Kind == "at" { + if job.DeleteAfterRun { + cs.removeJobUnsafe(job.ID) + } else { + job.Enabled = false + job.State.NextRunAtMS = nil + } + } else { + nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli()) + job.State.NextRunAtMS = nextRun + } if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store: %v", err) @@ -359,6 +381,20 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string return &job, nil } +func (cs *CronService) UpdateJob(job *CronJob) error { + cs.mu.Lock() + defer cs.mu.Unlock() + + for i := range cs.store.Jobs { + if cs.store.Jobs[i].ID == job.ID { + cs.store.Jobs[i] = *job + cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli() + return cs.saveStoreUnsafe() + } + } + return fmt.Errorf("job not found") +} + func (cs *CronService) RemoveJob(jobID string) bool { cs.mu.Lock() defer cs.mu.Unlock() diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go index 846fca21e..df5d44d0b 100644 --- a/pkg/cron/service_test.go +++ b/pkg/cron/service_test.go @@ -7,27 +7,47 @@ import ( "testing" ) -func TestAddJobWritesPrivatePermissions(t *testing.T) { +func TestSaveStore_FilePermissions(t *testing.T) { if runtime.GOOS == "windows" { - t.Skip("file permission bits are not reliable on windows") + t.Skip("file permission bits are not enforced on Windows") + } + + tmpDir := t.TempDir() + storePath := filepath.Join(tmpDir, "cron", "jobs.json") + + cs := NewCronService(storePath, nil) + + _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct") + if err != nil { + t.Fatalf("AddJob failed: %v", err) + } + + info, err := os.Stat(storePath) + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + + perm := info.Mode().Perm() + if perm != 0600 { + t.Errorf("cron store has permission %04o, want 0600", perm) + } +} + +func TestSaveStoreFixesExistingFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permission bits are not enforced on Windows") } storePath := filepath.Join(t.TempDir(), "cron", "jobs.json") - if err := os.MkdirAll(filepath.Dir(storePath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil { t.Fatalf("failed to create store dir: %v", err) } - if err := os.WriteFile(storePath, []byte(`{"version":1,"jobs":[]}`), 0644); err != nil { + if err := os.WriteFile(storePath, []byte(`{"version":1,"jobs":[]}`), 0o644); err != nil { t.Fatalf("failed to create seed cron store: %v", err) } cs := NewCronService(storePath, nil) - everyMS := int64(60000) - schedule := CronSchedule{ - Kind: "every", - EveryMS: &everyMS, - } - - if _, err := cs.AddJob("perm-test", schedule, "hello", true, "cli", "direct"); err != nil { + if _, err := cs.AddJob("perm-test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", true, "cli", "direct"); err != nil { t.Fatalf("AddJob failed: %v", err) } @@ -36,7 +56,11 @@ func TestAddJobWritesPrivatePermissions(t *testing.T) { t.Fatalf("stat cron store: %v", err) } - if got := info.Mode().Perm(); got != 0600 { + if got := info.Mode().Perm(); got != 0o600 { t.Fatalf("cron store perms = %o, want 600", got) } } + +func int64Ptr(v int64) *int64 { + return &v +} diff --git a/pkg/devices/events/events.go b/pkg/devices/events/events.go new file mode 100644 index 000000000..01226179c --- /dev/null +++ b/pkg/devices/events/events.go @@ -0,0 +1,57 @@ +package events + +import "context" + +type EventSource interface { + Kind() Kind + Start(ctx context.Context) (<-chan *DeviceEvent, error) + Stop() error +} + +type Action string + +const ( + ActionAdd Action = "add" + ActionRemove Action = "remove" + ActionChange Action = "change" +) + +type Kind string + +const ( + KindUSB Kind = "usb" + KindBluetooth Kind = "bluetooth" + KindPCI Kind = "pci" + KindGeneric Kind = "generic" +) + +type DeviceEvent struct { + Action Action + Kind Kind + DeviceID string // e.g. "1-2" for USB bus 1 dev 2 + Vendor string // Vendor name or ID + Product string // Product name or ID + Serial string // Serial number if available + Capabilities string // Human-readable capability description + Raw map[string]string // Raw properties for extensibility +} + +func (e *DeviceEvent) FormatMessage() string { + actionEmoji := "🔌" + actionText := "Connected" + if e.Action == ActionRemove { + actionEmoji = "🔌" + actionText = "Disconnected" + } + + msg := actionEmoji + " Device " + actionText + "\n\n" + msg += "Type: " + string(e.Kind) + "\n" + msg += "Device: " + e.Vendor + " " + e.Product + "\n" + if e.Capabilities != "" { + msg += "Capabilities: " + e.Capabilities + "\n" + } + if e.Serial != "" { + msg += "Serial: " + e.Serial + "\n" + } + return msg +} diff --git a/pkg/devices/service.go b/pkg/devices/service.go new file mode 100644 index 000000000..05a254729 --- /dev/null +++ b/pkg/devices/service.go @@ -0,0 +1,152 @@ +package devices + +import ( + "context" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/devices/events" + "github.com/sipeed/picoclaw/pkg/devices/sources" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/state" +) + +type Service struct { + bus *bus.MessageBus + state *state.Manager + sources []events.EventSource + enabled bool + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex +} + +type Config struct { + Enabled bool + MonitorUSB bool // When true, monitor USB hotplug (Linux only) + // Future: MonitorBluetooth, MonitorPCI, etc. +} + +func NewService(cfg Config, stateMgr *state.Manager) *Service { + s := &Service{ + state: stateMgr, + enabled: cfg.Enabled, + sources: make([]EventSource, 0), + } + + if cfg.Enabled && cfg.MonitorUSB { + s.sources = append(s.sources, sources.NewUSBMonitor()) + } + + return s +} + +func (s *Service) SetBus(msgBus *bus.MessageBus) { + s.mu.Lock() + defer s.mu.Unlock() + s.bus = msgBus +} + +func (s *Service) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.enabled || len(s.sources) == 0 { + logger.InfoC("devices", "Device event service disabled or no sources") + return nil + } + + s.ctx, s.cancel = context.WithCancel(ctx) + + for _, src := range s.sources { + eventCh, err := src.Start(s.ctx) + if err != nil { + logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{ + "kind": src.Kind(), + "error": err.Error(), + }) + continue + } + go s.handleEvents(src.Kind(), eventCh) + logger.InfoCF("devices", "Device source started", map[string]interface{}{ + "kind": src.Kind(), + }) + } + + logger.InfoC("devices", "Device event service started") + return nil +} + +func (s *Service) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + + for _, src := range s.sources { + src.Stop() + } + + logger.InfoC("devices", "Device event service stopped") +} + +func (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) { + for ev := range eventCh { + if ev == nil { + continue + } + s.sendNotification(ev) + } +} + +func (s *Service) sendNotification(ev *events.DeviceEvent) { + s.mu.RLock() + msgBus := s.bus + s.mu.RUnlock() + + if msgBus == nil { + return + } + + lastChannel := s.state.GetLastChannel() + if lastChannel == "" { + logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{ + "event": ev.FormatMessage(), + }) + return + } + + platform, userID := parseLastChannel(lastChannel) + if platform == "" || userID == "" || constants.IsInternalChannel(platform) { + return + } + + msg := ev.FormatMessage() + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: platform, + ChatID: userID, + Content: msg, + }) + + logger.InfoCF("devices", "Device notification sent", map[string]interface{}{ + "kind": ev.Kind, + "action": ev.Action, + "to": platform, + }) +} + +func parseLastChannel(lastChannel string) (platform, userID string) { + if lastChannel == "" { + return "", "" + } + parts := strings.SplitN(lastChannel, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "" + } + return parts[0], parts[1] +} diff --git a/pkg/devices/source.go b/pkg/devices/source.go new file mode 100644 index 000000000..cbf0a7d88 --- /dev/null +++ b/pkg/devices/source.go @@ -0,0 +1,5 @@ +package devices + +import "github.com/sipeed/picoclaw/pkg/devices/events" + +type EventSource = events.EventSource diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go new file mode 100644 index 000000000..1f6c068b3 --- /dev/null +++ b/pkg/devices/sources/usb_linux.go @@ -0,0 +1,198 @@ +//go:build linux + +package sources + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/devices/events" + "github.com/sipeed/picoclaw/pkg/logger" +) + +var usbClassToCapability = map[string]string{ + "00": "Interface Definition (by interface)", + "01": "Audio", + "02": "CDC Communication (Network Card/Modem)", + "03": "HID (Keyboard/Mouse/Gamepad)", + "05": "Physical Interface", + "06": "Image (Scanner/Camera)", + "07": "Printer", + "08": "Mass Storage (USB Flash Drive/Hard Disk)", + "09": "USB Hub", + "0a": "CDC Data", + "0b": "Smart Card", + "0e": "Video (Camera)", + "dc": "Diagnostic Device", + "e0": "Wireless Controller (Bluetooth)", + "ef": "Miscellaneous", + "fe": "Application Specific", + "ff": "Vendor Specific", +} + +type USBMonitor struct { + cmd *exec.Cmd + cancel context.CancelFunc + mu sync.Mutex +} + +func NewUSBMonitor() *USBMonitor { + return &USBMonitor{} +} + +func (m *USBMonitor) Kind() events.Kind { + return events.KindUSB +} + +func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem) + // Followed by KEY=value lines, empty line separates events + // Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev) + cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb") + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("udevadm stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err) + } + + m.cmd = cmd + eventCh := make(chan *events.DeviceEvent, 16) + + go func() { + defer close(eventCh) + scanner := bufio.NewScanner(stdout) + var props map[string]string + var action string + isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + // End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications) + if isUdev && props != nil && (action == "add" || action == "remove") { + if ev := parseUSBEvent(action, props); ev != nil { + select { + case eventCh <- ev: + case <-ctx.Done(): + return + } + } + } + props = nil + action = "" + isUdev = false + continue + } + + idx := strings.Index(line, "=") + // First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value + if idx <= 0 { + isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV") + continue + } + + // Parse KEY=value + key := line[:idx] + val := line[idx+1:] + if props == nil { + props = make(map[string]string) + } + props[key] = val + + if key == "ACTION" { + action = val + } + } + + if err := scanner.Err(); err != nil { + logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()}) + } + cmd.Wait() + }() + + return eventCh, nil +} + +func (m *USBMonitor) Stop() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.cmd != nil && m.cmd.Process != nil { + m.cmd.Process.Kill() + m.cmd = nil + } + return nil +} + +func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent { + // Only care about add/remove for physical devices (not interfaces) + subsystem := props["SUBSYSTEM"] + if subsystem != "usb" { + return nil + } + // Skip interface events - we want device-level only to avoid duplicates + devType := props["DEVTYPE"] + if devType == "usb_interface" { + return nil + } + // Prefer usb_device, but accept if DEVTYPE not set (varies by udev version) + if devType != "" && devType != "usb_device" { + return nil + } + + ev := &events.DeviceEvent{ + Raw: props, + } + switch action { + case "add": + ev.Action = events.ActionAdd + case "remove": + ev.Action = events.ActionRemove + default: + return nil + } + ev.Kind = events.KindUSB + + ev.Vendor = props["ID_VENDOR"] + if ev.Vendor == "" { + ev.Vendor = props["ID_VENDOR_ID"] + } + if ev.Vendor == "" { + ev.Vendor = "Unknown Vendor" + } + + ev.Product = props["ID_MODEL"] + if ev.Product == "" { + ev.Product = props["ID_MODEL_ID"] + } + if ev.Product == "" { + ev.Product = "Unknown Device" + } + + ev.Serial = props["ID_SERIAL_SHORT"] + ev.DeviceID = props["DEVPATH"] + if bus := props["BUSNUM"]; bus != "" { + if dev := props["DEVNUM"]; dev != "" { + ev.DeviceID = bus + ":" + dev + } + } + + // Map USB class to capability + if class := props["ID_USB_CLASS"]; class != "" { + ev.Capabilities = usbClassToCapability[strings.ToLower(class)] + } + if ev.Capabilities == "" { + ev.Capabilities = "USB Device" + } + + return ev +} diff --git a/pkg/devices/sources/usb_stub.go b/pkg/devices/sources/usb_stub.go new file mode 100644 index 000000000..f08c2d406 --- /dev/null +++ b/pkg/devices/sources/usb_stub.go @@ -0,0 +1,29 @@ +//go:build !linux + +package sources + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/devices/events" +) + +type USBMonitor struct{} + +func NewUSBMonitor() *USBMonitor { + return &USBMonitor{} +} + +func (m *USBMonitor) Kind() events.Kind { + return events.KindUSB +} + +func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) { + ch := make(chan *events.DeviceEvent) + close(ch) // Immediately close, no events + return ch, nil +} + +func (m *USBMonitor) Stop() error { + return nil +} diff --git a/pkg/health/server.go b/pkg/health/server.go new file mode 100644 index 000000000..77b36034d --- /dev/null +++ b/pkg/health/server.go @@ -0,0 +1,164 @@ +package health + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" +) + +type Server struct { + server *http.Server + mu sync.RWMutex + ready bool + checks map[string]Check + startTime time.Time +} + +type Check struct { + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +type StatusResponse struct { + Status string `json:"status"` + Uptime string `json:"uptime"` + Checks map[string]Check `json:"checks,omitempty"` +} + +func NewServer(host string, port int) *Server { + mux := http.NewServeMux() + s := &Server{ + ready: false, + checks: make(map[string]Check), + startTime: time.Now(), + } + + mux.HandleFunc("/health", s.healthHandler) + mux.HandleFunc("/ready", s.readyHandler) + + addr := fmt.Sprintf("%s:%d", host, port) + s.server = &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + } + + return s +} + +func (s *Server) Start() error { + s.mu.Lock() + s.ready = true + s.mu.Unlock() + return s.server.ListenAndServe() +} + +func (s *Server) StartContext(ctx context.Context) error { + s.mu.Lock() + s.ready = true + s.mu.Unlock() + + errCh := make(chan error, 1) + go func() { + errCh <- s.server.ListenAndServe() + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + return s.server.Shutdown(context.Background()) + } +} + +func (s *Server) Stop(ctx context.Context) error { + s.mu.Lock() + s.ready = false + s.mu.Unlock() + return s.server.Shutdown(ctx) +} + +func (s *Server) SetReady(ready bool) { + s.mu.Lock() + s.ready = ready + s.mu.Unlock() +} + +func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) { + s.mu.Lock() + defer s.mu.Unlock() + + status, msg := checkFn() + s.checks[name] = Check{ + Name: name, + Status: statusString(status), + Message: msg, + Timestamp: time.Now(), + } +} + +func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + uptime := time.Since(s.startTime) + resp := StatusResponse{ + Status: "ok", + Uptime: uptime.String(), + } + + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + s.mu.RLock() + ready := s.ready + checks := make(map[string]Check) + for k, v := range s.checks { + checks[k] = v + } + s.mu.RUnlock() + + if !ready { + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(StatusResponse{ + Status: "not ready", + Checks: checks, + }) + return + } + + for _, check := range checks { + if check.Status == "fail" { + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(StatusResponse{ + Status: "not ready", + Checks: checks, + }) + return + } + } + + w.WriteHeader(http.StatusOK) + uptime := time.Since(s.startTime) + json.NewEncoder(w).Encode(StatusResponse{ + Status: "ready", + Uptime: uptime.String(), + Checks: checks, + }) +} + +func statusString(ok bool) string { + if ok { + return "ok" + } + return "fail" +} diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index ba85d713f..dfdaef58b 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -1,128 +1,359 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + package heartbeat import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/tools" ) +const ( + minIntervalMinutes = 5 + defaultIntervalMinutes = 30 +) + +// HeartbeatHandler is the function type for handling heartbeat. +// It returns a ToolResult that can indicate async operations. +// channel and chatID are derived from the last active user channel. +type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult + +// HeartbeatService manages periodic heartbeat checks type HeartbeatService struct { - workspace string - onHeartbeat func(string) (string, error) - interval time.Duration - enabled bool - mu sync.RWMutex - stopChan chan struct{} + workspace string + bus *bus.MessageBus + state *state.Manager + handler HeartbeatHandler + interval time.Duration + enabled bool + mu sync.RWMutex + stopChan chan struct{} } -func NewHeartbeatService(workspace string, onHeartbeat func(string) (string, error), intervalS int, enabled bool) *HeartbeatService { +// NewHeartbeatService creates a new heartbeat service +func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService { + // Apply minimum interval + if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 { + intervalMinutes = minIntervalMinutes + } + + if intervalMinutes == 0 { + intervalMinutes = defaultIntervalMinutes + } + return &HeartbeatService{ - workspace: workspace, - onHeartbeat: onHeartbeat, - interval: time.Duration(intervalS) * time.Second, - enabled: enabled, - stopChan: make(chan struct{}), + workspace: workspace, + interval: time.Duration(intervalMinutes) * time.Minute, + enabled: enabled, + state: state.NewManager(workspace), } } +// SetBus sets the message bus for delivering heartbeat results. +func (hs *HeartbeatService) SetBus(msgBus *bus.MessageBus) { + hs.mu.Lock() + defer hs.mu.Unlock() + hs.bus = msgBus +} + +// SetHandler sets the heartbeat handler. +func (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) { + hs.mu.Lock() + defer hs.mu.Unlock() + hs.handler = handler +} + +// Start begins the heartbeat service func (hs *HeartbeatService) Start() error { hs.mu.Lock() defer hs.mu.Unlock() - if hs.running() { + if hs.stopChan != nil { + logger.InfoC("heartbeat", "Heartbeat service already running") return nil } if !hs.enabled { - return fmt.Errorf("heartbeat service is disabled") + logger.InfoC("heartbeat", "Heartbeat service disabled") + return nil } - go hs.runLoop() + hs.stopChan = make(chan struct{}) + go hs.runLoop(hs.stopChan) + + logger.InfoCF("heartbeat", "Heartbeat service started", map[string]any{ + "interval_minutes": hs.interval.Minutes(), + }) return nil } +// Stop gracefully stops the heartbeat service func (hs *HeartbeatService) Stop() { hs.mu.Lock() defer hs.mu.Unlock() - if !hs.running() { + if hs.stopChan == nil { return } + logger.InfoC("heartbeat", "Stopping heartbeat service") close(hs.stopChan) + hs.stopChan = nil } -func (hs *HeartbeatService) running() bool { - select { - case <-hs.stopChan: - return false - default: - return true - } +// IsRunning returns whether the service is running +func (hs *HeartbeatService) IsRunning() bool { + hs.mu.RLock() + defer hs.mu.RUnlock() + return hs.stopChan != nil } -func (hs *HeartbeatService) runLoop() { +// runLoop runs the heartbeat ticker +func (hs *HeartbeatService) runLoop(stopChan chan struct{}) { ticker := time.NewTicker(hs.interval) defer ticker.Stop() + // Run first heartbeat after initial delay + time.AfterFunc(time.Second, func() { + hs.executeHeartbeat() + }) + for { select { - case <-hs.stopChan: + case <-stopChan: return case <-ticker.C: - hs.checkHeartbeat() + hs.executeHeartbeat() } } } -func (hs *HeartbeatService) checkHeartbeat() { +// executeHeartbeat performs a single heartbeat check +func (hs *HeartbeatService) executeHeartbeat() { hs.mu.RLock() - if !hs.enabled || !hs.running() { + enabled := hs.enabled + handler := hs.handler + if !hs.enabled || hs.stopChan == nil { hs.mu.RUnlock() return } hs.mu.RUnlock() - prompt := hs.buildPrompt() - - if hs.onHeartbeat != nil { - _, err := hs.onHeartbeat(prompt) - if err != nil { - hs.log(fmt.Sprintf("Heartbeat error: %v", err)) - } + if !enabled { + return } + + logger.DebugC("heartbeat", "Executing heartbeat") + + prompt := hs.buildPrompt() + if prompt == "" { + logger.InfoC("heartbeat", "No heartbeat prompt (HEARTBEAT.md empty or missing)") + return + } + + if handler == nil { + hs.logError("Heartbeat handler not configured") + return + } + + // Get last channel info for context + lastChannel := hs.state.GetLastChannel() + channel, chatID := hs.parseLastChannel(lastChannel) + + // Debug log for channel resolution + hs.logInfo("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel) + + result := handler(prompt, channel, chatID) + + if result == nil { + hs.logInfo("Heartbeat handler returned nil result") + return + } + + // Handle different result types + if result.IsError { + hs.logError("Heartbeat error: %s", result.ForLLM) + return + } + + if result.Async { + hs.logInfo("Async task started: %s", result.ForLLM) + logger.InfoCF("heartbeat", "Async heartbeat task started", + map[string]interface{}{ + "message": result.ForLLM, + }) + return + } + + // Check if silent + if result.Silent { + hs.logInfo("Heartbeat OK - silent") + return + } + + // Send result to user + if result.ForUser != "" { + hs.sendResponse(result.ForUser) + } else if result.ForLLM != "" { + hs.sendResponse(result.ForLLM) + } + + hs.logInfo("Heartbeat completed: %s", result.ForLLM) } +// buildPrompt builds the heartbeat prompt from HEARTBEAT.md func (hs *HeartbeatService) buildPrompt() string { - notesDir := filepath.Join(hs.workspace, "memory") - notesFile := filepath.Join(notesDir, "HEARTBEAT.md") + heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md") - var notes string - if data, err := os.ReadFile(notesFile); err == nil { - notes = string(data) + data, err := os.ReadFile(heartbeatPath) + if err != nil { + if os.IsNotExist(err) { + hs.createDefaultHeartbeatTemplate() + return "" + } + hs.logError("Error reading HEARTBEAT.md: %v", err) + return "" } - now := time.Now().Format("2006-01-02 15:04") + content := string(data) + if len(content) == 0 { + return "" + } - prompt := fmt.Sprintf(`# Heartbeat Check + now := time.Now().Format("2006-01-02 15:04:05") + return fmt.Sprintf(`# Heartbeat Check Current time: %s -Check if there are any tasks I should be aware of or actions I should take. -Review the memory file for any important updates or changes. -Be proactive in identifying potential issues or improvements. +You are a proactive AI assistant. This is a scheduled heartbeat check. +Review the following tasks and execute any necessary actions using available skills. +If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK %s -`, now, notes) - - return prompt +`, now, content) } -func (hs *HeartbeatService) log(message string) { - logFile := filepath.Join(hs.workspace, "memory", "heartbeat.log") +// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file +func (hs *HeartbeatService) createDefaultHeartbeatTemplate() { + heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md") + + defaultContent := `# Heartbeat Check List + +This file contains tasks for the heartbeat service to check periodically. + +## Examples + +- Check for unread messages +- Review upcoming calendar events +- Check device status (e.g., MaixCam) + +## Instructions + +- Execute ALL tasks listed below. Do NOT skip any task. +- For simple tasks (e.g., report current time), respond directly. +- For complex tasks that may take time, use the spawn tool to create a subagent. +- The spawn tool is async - subagent results will be sent to the user automatically. +- After spawning a subagent, CONTINUE to process remaining tasks. +- Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention. + +--- + +Add your heartbeat tasks below this line: +` + + if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil { + hs.logError("Failed to create default HEARTBEAT.md: %v", err) + } else { + hs.logInfo("Created default HEARTBEAT.md template") + } +} + +// sendResponse sends the heartbeat response to the last channel +func (hs *HeartbeatService) sendResponse(response string) { + hs.mu.RLock() + msgBus := hs.bus + hs.mu.RUnlock() + + if msgBus == nil { + hs.logInfo("No message bus configured, heartbeat result not sent") + return + } + + // Get last channel from state + lastChannel := hs.state.GetLastChannel() + if lastChannel == "" { + hs.logInfo("No last channel recorded, heartbeat result not sent") + return + } + + platform, userID := hs.parseLastChannel(lastChannel) + + // Skip internal channels that can't receive messages + if platform == "" || userID == "" { + return + } + + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: platform, + ChatID: userID, + Content: response, + }) + + hs.logInfo("Heartbeat result sent to %s", platform) +} + +// parseLastChannel parses the last channel string into platform and userID. +// Returns empty strings for invalid or internal channels. +func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) { + if lastChannel == "" { + return "", "" + } + + // Parse channel format: "platform:user_id" (e.g., "telegram:123456") + parts := strings.SplitN(lastChannel, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + hs.logError("Invalid last channel format: %s", lastChannel) + return "", "" + } + + platform, userID = parts[0], parts[1] + + // Skip internal channels + if constants.IsInternalChannel(platform) { + hs.logInfo("Skipping internal channel: %s", platform) + return "", "" + } + + return platform, userID +} + +// logInfo logs an informational message to the heartbeat log +func (hs *HeartbeatService) logInfo(format string, args ...any) { + hs.log("INFO", format, args...) +} + +// logError logs an error message to the heartbeat log +func (hs *HeartbeatService) logError(format string, args ...any) { + hs.log("ERROR", format, args...) +} + +// log writes a message to the heartbeat log file +func (hs *HeartbeatService) log(level, format string, args ...any) { + logFile := filepath.Join(hs.workspace, "heartbeat.log") f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return @@ -130,5 +361,5 @@ func (hs *HeartbeatService) log(message string) { defer f.Close() timestamp := time.Now().Format("2006-01-02 15:04:05") - f.WriteString(fmt.Sprintf("[%s] %s\n", timestamp, message)) + fmt.Fprintf(f, "[%s] [%s] %s\n", timestamp, level, fmt.Sprintf(format, args...)) } diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go new file mode 100644 index 000000000..a2b59e350 --- /dev/null +++ b/pkg/heartbeat/service_test.go @@ -0,0 +1,221 @@ +package heartbeat + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/tools" +) + +func TestExecuteHeartbeat_Async(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) // Enable for testing + + asyncCalled := false + asyncResult := &tools.ToolResult{ + ForLLM: "Background task started", + ForUser: "Task started in background", + Silent: false, + IsError: false, + Async: true, + } + + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + asyncCalled = true + if prompt == "" { + t.Error("Expected non-empty prompt") + } + return asyncResult + }) + + // Create HEARTBEAT.md + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + + // Execute heartbeat directly (internal method for testing) + hs.executeHeartbeat() + + if !asyncCalled { + t.Error("Expected handler to be called") + } +} + +func TestExecuteHeartbeat_Error(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) // Enable for testing + + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + return &tools.ToolResult{ + ForLLM: "Heartbeat failed: connection error", + ForUser: "", + Silent: false, + IsError: true, + Async: false, + } + }) + + // Create HEARTBEAT.md + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + + hs.executeHeartbeat() + + // Check log file for error message + logFile := filepath.Join(tmpDir, "heartbeat.log") + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + logContent := string(data) + if logContent == "" { + t.Error("Expected log file to contain error message") + } +} + +func TestExecuteHeartbeat_Silent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) // Enable for testing + + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + return &tools.ToolResult{ + ForLLM: "Heartbeat completed successfully", + ForUser: "", + Silent: true, + IsError: false, + Async: false, + } + }) + + // Create HEARTBEAT.md + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + + hs.executeHeartbeat() + + // Check log file for completion message + logFile := filepath.Join(tmpDir, "heartbeat.log") + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("Failed to read log file: %v", err) + } + + logContent := string(data) + if logContent == "" { + t.Error("Expected log file to contain completion message") + } +} + +func TestHeartbeatService_StartStop(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 1, true) + + err = hs.Start() + if err != nil { + t.Fatalf("Failed to start heartbeat service: %v", err) + } + + hs.Stop() + + time.Sleep(100 * time.Millisecond) +} + +func TestHeartbeatService_Disabled(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 1, false) + + if hs.enabled != false { + t.Error("Expected service to be disabled") + } + + err = hs.Start() + _ = err // Disabled service returns nil +} + +func TestExecuteHeartbeat_NilResult(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) // Enable for testing + + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + return nil + }) + + // Create HEARTBEAT.md + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644) + + // Should not panic with nil result + hs.executeHeartbeat() +} + +// TestLogPath verifies heartbeat log is written to workspace directory +func TestLogPath(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + + // Write a log entry + hs.log("INFO", "Test log entry") + + // Verify log file exists at workspace root + expectedLogPath := filepath.Join(tmpDir, "heartbeat.log") + if _, err := os.Stat(expectedLogPath); os.IsNotExist(err) { + t.Errorf("Expected log file at %s, but it doesn't exist", expectedLogPath) + } +} + +// TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root +func TestHeartbeatFilePath(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + + // Trigger default template creation + hs.buildPrompt() + + // Verify HEARTBEAT.md exists at workspace root + expectedPath := filepath.Join(tmpDir, "HEARTBEAT.md") + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { + t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath) + } +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index d7fa63305..57032e566 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -27,7 +27,7 @@ var supportedChannels = map[string]bool{ "whatsapp": true, "feishu": true, "qq": true, - "dingtalk": true, + "dingtalk": true, "maixcam": true, } @@ -108,7 +108,10 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error case "anthropic": cfg.Providers.Anthropic = pc case "openai": - cfg.Providers.OpenAI = pc + cfg.Providers.OpenAI = config.OpenAIProviderConfig{ + ProviderConfig: pc, + WebSearch: getBoolOrDefault(pMap, "web_search", true), + } case "openrouter": cfg.Providers.OpenRouter = pc case "groq": @@ -212,12 +215,17 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error if tools, ok := getMap(data, "tools"); ok { if web, ok := getMap(tools, "web"); ok { + // Migrate old "search" config to "brave" if api_key is present if search, ok := getMap(web, "search"); ok { if v, ok := getString(search, "api_key"); ok { - cfg.Tools.Web.Search.APIKey = v + cfg.Tools.Web.Brave.APIKey = v + if v != "" { + cfg.Tools.Web.Brave.Enabled = true + } } if v, ok := getFloat(search, "max_results"); ok { - cfg.Tools.Web.Search.MaxResults = int(v) + cfg.Tools.Web.Brave.MaxResults = int(v) + cfg.Tools.Web.DuckDuckGo.MaxResults = int(v) } } } @@ -271,8 +279,8 @@ func MergeConfig(existing, incoming *config.Config) *config.Config { existing.Channels.MaixCam = incoming.Channels.MaixCam } - if existing.Tools.Web.Search.APIKey == "" { - existing.Tools.Web.Search = incoming.Tools.Web.Search + if existing.Tools.Web.Brave.APIKey == "" { + existing.Tools.Web.Brave = incoming.Tools.Web.Brave } return existing @@ -358,6 +366,13 @@ func getBool(data map[string]interface{}, key string) (bool, bool) { return b, ok } +func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool { + if v, ok := getBool(data, key); ok { + return v + } + return defaultVal +} + func getStringSlice(data map[string]interface{}, key string) []string { v, ok := data[key] if !ok { diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index ec5088846..d28d9e6ec 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -299,6 +299,24 @@ func TestConvertConfig(t *testing.T) { }) } +func TestSupportedProvidersCompatibility(t *testing.T) { + expected := []string{ + "anthropic", + "openai", + "openrouter", + "groq", + "zhipu", + "vllm", + "gemini", + } + + for _, provider := range expected { + if !supportedProviders[provider] { + t.Fatalf("supportedProviders missing expected key %q", provider) + } + } +} + func TestMergeConfig(t *testing.T) { t.Run("fills empty fields", func(t *testing.T) { existing := config.DefaultConfig() diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go new file mode 100644 index 000000000..8f46aa70c --- /dev/null +++ b/pkg/providers/anthropic/provider.go @@ -0,0 +1,248 @@ +package anthropicprovider + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + +const defaultBaseURL = "https://api.anthropic.com" + +type Provider struct { + client *anthropic.Client + tokenSource func() (string, error) + baseURL string +} + +func NewProvider(token string) *Provider { + return NewProviderWithBaseURL(token, "") +} + +func NewProviderWithBaseURL(token, apiBase string) *Provider { + baseURL := normalizeBaseURL(apiBase) + client := anthropic.NewClient( + option.WithAuthToken(token), + option.WithBaseURL(baseURL), + ) + return &Provider{ + client: &client, + baseURL: baseURL, + } +} + +func NewProviderWithClient(client *anthropic.Client) *Provider { + return &Provider{ + client: client, + baseURL: defaultBaseURL, + } +} + +func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider { + return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "") +} + +func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider { + p := NewProviderWithBaseURL(token, apiBase) + p.tokenSource = tokenSource + return p +} + +func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + var opts []option.RequestOption + if p.tokenSource != nil { + tok, err := p.tokenSource() + if err != nil { + return nil, fmt.Errorf("refreshing token: %w", err) + } + opts = append(opts, option.WithAuthToken(tok)) + } + + params, err := buildParams(messages, tools, model, options) + if err != nil { + return nil, err + } + + resp, err := p.client.Messages.New(ctx, params, opts...) + if err != nil { + return nil, fmt.Errorf("claude API call: %w", err) + } + + return parseResponse(resp), nil +} + +func (p *Provider) GetDefaultModel() string { + return "claude-sonnet-4-5-20250929" +} + +func (p *Provider) BaseURL() string { + return p.baseURL +} + +func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { + var system []anthropic.TextBlockParam + var anthropicMessages []anthropic.MessageParam + + for _, msg := range messages { + switch msg.Role { + case "system": + system = append(system, anthropic.TextBlockParam{Text: msg.Content}) + case "user": + if msg.ToolCallID != "" { + anthropicMessages = append(anthropicMessages, + anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), + ) + } else { + anthropicMessages = append(anthropicMessages, + anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), + ) + } + case "assistant": + if len(msg.ToolCalls) > 0 { + var blocks []anthropic.ContentBlockParamUnion + if msg.Content != "" { + blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) + } + for _, tc := range msg.ToolCalls { + blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) + } + anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) + } else { + anthropicMessages = append(anthropicMessages, + anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), + ) + } + case "tool": + anthropicMessages = append(anthropicMessages, + anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), + ) + } + } + + maxTokens := int64(4096) + if mt, ok := options["max_tokens"].(int); ok { + maxTokens = int64(mt) + } + + params := anthropic.MessageNewParams{ + Model: anthropic.Model(model), + Messages: anthropicMessages, + MaxTokens: maxTokens, + } + + if len(system) > 0 { + params.System = system + } + + if temp, ok := options["temperature"].(float64); ok { + params.Temperature = anthropic.Float(temp) + } + + if len(tools) > 0 { + params.Tools = translateTools(tools) + } + + return params, nil +} + +func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { + result := make([]anthropic.ToolUnionParam, 0, len(tools)) + for _, t := range tools { + tool := anthropic.ToolParam{ + Name: t.Function.Name, + InputSchema: anthropic.ToolInputSchemaParam{ + Properties: t.Function.Parameters["properties"], + }, + } + if desc := t.Function.Description; desc != "" { + tool.Description = anthropic.String(desc) + } + if req, ok := t.Function.Parameters["required"].([]interface{}); ok { + required := make([]string, 0, len(req)) + for _, r := range req { + if s, ok := r.(string); ok { + required = append(required, s) + } + } + tool.InputSchema.Required = required + } + result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) + } + return result +} + +func parseResponse(resp *anthropic.Message) *LLMResponse { + var content string + var toolCalls []ToolCall + + for _, block := range resp.Content { + switch block.Type { + case "text": + tb := block.AsText() + content += tb.Text + case "tool_use": + tu := block.AsToolUse() + var args map[string]interface{} + if err := json.Unmarshal(tu.Input, &args); err != nil { + log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err) + args = map[string]interface{}{"raw": string(tu.Input)} + } + toolCalls = append(toolCalls, ToolCall{ + ID: tu.ID, + Name: tu.Name, + Arguments: args, + }) + } + } + + finishReason := "stop" + switch resp.StopReason { + case anthropic.StopReasonToolUse: + finishReason = "tool_calls" + case anthropic.StopReasonMaxTokens: + finishReason = "length" + case anthropic.StopReasonEndTurn: + finishReason = "stop" + } + + return &LLMResponse{ + Content: content, + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: &UsageInfo{ + PromptTokens: int(resp.Usage.InputTokens), + CompletionTokens: int(resp.Usage.OutputTokens), + TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), + }, + } +} + +func normalizeBaseURL(apiBase string) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return defaultBaseURL + } + + base = strings.TrimRight(base, "/") + if strings.HasSuffix(base, "/v1") { + base = strings.TrimSuffix(base, "/v1") + } + if base == "" { + return defaultBaseURL + } + + return base +} diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go new file mode 100644 index 000000000..6a1dabafb --- /dev/null +++ b/pkg/providers/anthropic/provider_test.go @@ -0,0 +1,265 @@ +package anthropicprovider + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" +) + +func TestBuildParams_BasicMessage(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "Hello"}, + } + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{ + "max_tokens": 1024, + }) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if string(params.Model) != "claude-sonnet-4-5-20250929" { + t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929") + } + if params.MaxTokens != 1024 { + t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens) + } + if len(params.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) + } +} + +func TestBuildParams_SystemMessage(t *testing.T) { + messages := []Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hi"}, + } + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if len(params.System) != 1 { + t.Fatalf("len(System) = %d, want 1", len(params.System)) + } + if params.System[0].Text != "You are helpful" { + t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful") + } + if len(params.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) + } +} + +func TestBuildParams_ToolCallMessage(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "What's the weather?"}, + { + Role: "assistant", + Content: "", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Name: "get_weather", + Arguments: map[string]interface{}{"city": "SF"}, + }, + }, + }, + {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, + } + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if len(params.Messages) != 3 { + t.Fatalf("len(Messages) = %d, want 3", len(params.Messages)) + } +} + +func TestBuildParams_WithTools(t *testing.T) { + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get weather for a city", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "city": map[string]interface{}{"type": "string"}, + }, + "required": []interface{}{"city"}, + }, + }, + }, + } + params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if len(params.Tools) != 1 { + t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) + } +} + +func TestParseResponse_TextOnly(t *testing.T) { + resp := &anthropic.Message{ + Content: []anthropic.ContentBlockUnion{}, + Usage: anthropic.Usage{ + InputTokens: 10, + OutputTokens: 20, + }, + } + result := parseResponse(resp) + if result.Usage.PromptTokens != 10 { + t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) + } + if result.Usage.CompletionTokens != 20 { + t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens) + } + if result.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") + } +} + +func TestParseResponse_StopReasons(t *testing.T) { + tests := []struct { + stopReason anthropic.StopReason + want string + }{ + {anthropic.StopReasonEndTurn, "stop"}, + {anthropic.StopReasonMaxTokens, "length"}, + {anthropic.StopReasonToolUse, "tool_calls"}, + } + for _, tt := range tests { + resp := &anthropic.Message{ + StopReason: tt.stopReason, + } + result := parseResponse(resp) + if result.FinishReason != tt.want { + t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want) + } + } +} + +func TestProvider_ChatRoundTrip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + + resp := map[string]interface{}{ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": reqBody["model"], + "stop_reason": "end_turn", + "content": []map[string]interface{}{ + {"type": "text", "text": "Hello! How can I help you?"}, + }, + "usage": map[string]interface{}{ + "input_tokens": 15, + "output_tokens": 8, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hello! How can I help you?" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage.PromptTokens != 15 { + t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens) + } +} + +func TestProvider_GetDefaultModel(t *testing.T) { + p := NewProvider("test-token") + if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" { + t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929") + } +} + +func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) { + p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/") + if got := p.BaseURL(); got != "https://api.anthropic.com" { + t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com") + } +} + +func TestProvider_ChatUsesTokenSource(t *testing.T) { + var requests int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + http.Error(w, "not found", http.StatusNotFound) + return + } + atomic.AddInt32(&requests, 1) + + if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + + resp := map[string]interface{}{ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": reqBody["model"], + "stop_reason": "end_turn", + "content": []map[string]interface{}{ + {"type": "text", "text": "ok"}, + }, + "usage": map[string]interface{}{ + "input_tokens": 1, + "output_tokens": 1, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) { + return "refreshed-token", nil + }, server.URL) + + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if got := atomic.LoadInt32(&requests); got != 1 { + t.Fatalf("requests = %d, want 1", got) + } +} + +func createAnthropicTestClient(baseURL, token string) *anthropic.Client { + c := anthropic.NewClient( + anthropicoption.WithAuthToken(token), + anthropicoption.WithBaseURL(baseURL), + ) + return &c +} diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go new file mode 100644 index 000000000..58ba3647d --- /dev/null +++ b/pkg/providers/claude_cli_provider.go @@ -0,0 +1,221 @@ +package providers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. +type ClaudeCliProvider struct { + command string + workspace string +} + +// NewClaudeCliProvider creates a new Claude CLI provider. +func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { + return &ClaudeCliProvider{ + command: "claude", + workspace: workspace, + } +} + +// Chat implements LLMProvider.Chat by executing the claude CLI. +func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + systemPrompt := p.buildSystemPrompt(messages, tools) + prompt := p.messagesToPrompt(messages) + + args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} + if systemPrompt != "" { + args = append(args, "--system-prompt", systemPrompt) + } + if model != "" && model != "claude-code" { + args = append(args, "--model", model) + } + args = append(args, "-") // read from stdin + + cmd := exec.CommandContext(ctx, p.command, args...) + if p.workspace != "" { + cmd.Dir = p.workspace + } + cmd.Stdin = bytes.NewReader([]byte(prompt)) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if stderrStr := stderr.String(); stderrStr != "" { + return nil, fmt.Errorf("claude cli error: %s", stderrStr) + } + return nil, fmt.Errorf("claude cli error: %w", err) + } + + return p.parseClaudeCliResponse(stdout.String()) +} + +// GetDefaultModel returns the default model identifier. +func (p *ClaudeCliProvider) GetDefaultModel() string { + return "claude-code" +} + +// messagesToPrompt converts messages to a CLI-compatible prompt string. +func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string { + var parts []string + + for _, msg := range messages { + switch msg.Role { + case "system": + // handled via --system-prompt flag + case "user": + parts = append(parts, "User: "+msg.Content) + case "assistant": + parts = append(parts, "Assistant: "+msg.Content) + case "tool": + parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) + } + } + + // Simplify single user message + if len(parts) == 1 && strings.HasPrefix(parts[0], "User: ") { + return strings.TrimPrefix(parts[0], "User: ") + } + + return strings.Join(parts, "\n") +} + +// buildSystemPrompt combines system messages and tool definitions. +func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string { + var parts []string + + for _, msg := range messages { + if msg.Role == "system" { + parts = append(parts, msg.Content) + } + } + + if len(tools) > 0 { + parts = append(parts, p.buildToolsPrompt(tools)) + } + + return strings.Join(parts, "\n\n") +} + +// buildToolsPrompt creates the tool definitions section for the system prompt. +func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { + var sb strings.Builder + + sb.WriteString("## Available Tools\n\n") + sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") + sb.WriteString("```json\n") + sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) + sb.WriteString("\n```\n\n") + sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") + sb.WriteString("### Tool Definitions:\n\n") + + for _, tool := range tools { + if tool.Type != "function" { + continue + } + sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) + if tool.Function.Description != "" { + sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) + } + if len(tool.Function.Parameters) > 0 { + paramsJSON, _ := json.Marshal(tool.Function.Parameters) + sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) + } + sb.WriteString("\n") + } + + return sb.String() +} + +// parseClaudeCliResponse parses the JSON output from the claude CLI. +func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { + var resp claudeCliJSONResponse + if err := json.Unmarshal([]byte(output), &resp); err != nil { + return nil, fmt.Errorf("failed to parse claude cli response: %w", err) + } + + if resp.IsError { + return nil, fmt.Errorf("claude cli returned error: %s", resp.Result) + } + + toolCalls := p.extractToolCalls(resp.Result) + + finishReason := "stop" + content := resp.Result + if len(toolCalls) > 0 { + finishReason = "tool_calls" + content = p.stripToolCallsJSON(resp.Result) + } + + var usage *UsageInfo + if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 { + usage = &UsageInfo{ + PromptTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens, + CompletionTokens: resp.Usage.OutputTokens, + TotalTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens, + } + } + + return &LLMResponse{ + Content: strings.TrimSpace(content), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} + +// extractToolCalls delegates to the shared extractToolCallsFromText function. +func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall { + return extractToolCallsFromText(text) +} + +// stripToolCallsJSON delegates to the shared stripToolCallsFromText function. +func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string { + return stripToolCallsFromText(text) +} + +// findMatchingBrace finds the index after the closing brace matching the opening brace at pos. +func findMatchingBrace(text string, pos int) int { + depth := 0 + for i := pos; i < len(text); i++ { + if text[i] == '{' { + depth++ + } else if text[i] == '}' { + depth-- + if depth == 0 { + return i + 1 + } + } + } + return pos +} + +// claudeCliJSONResponse represents the JSON output from the claude CLI. +// Matches the real claude CLI v2.x output format. +type claudeCliJSONResponse struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + IsError bool `json:"is_error"` + Result string `json:"result"` + SessionID string `json:"session_id"` + TotalCostUSD float64 `json:"total_cost_usd"` + DurationMS int `json:"duration_ms"` + DurationAPI int `json:"duration_api_ms"` + NumTurns int `json:"num_turns"` + Usage claudeCliUsageInfo `json:"usage"` +} + +// claudeCliUsageInfo represents token usage from the claude CLI response. +type claudeCliUsageInfo struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/claude_cli_provider_integration_test.go new file mode 100644 index 000000000..9d1131ac4 --- /dev/null +++ b/pkg/providers/claude_cli_provider_integration_test.go @@ -0,0 +1,126 @@ +//go:build integration + +package providers + +import ( + "context" + exec "os/exec" + "strings" + "testing" + "time" +) + +// TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI. +// Run with: go test -tags=integration ./pkg/providers/... +func TestIntegration_RealClaudeCLI(t *testing.T) { + // Check if claude CLI is available + path, err := exec.LookPath("claude") + if err != nil { + t.Skip("claude CLI not found in PATH, skipping integration test") + } + t.Logf("Using claude CLI at: %s", path) + + p := NewClaudeCliProvider(t.TempDir()) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + resp, err := p.Chat(ctx, []Message{ + {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, + }, nil, "", nil) + + if err != nil { + t.Fatalf("Chat() with real CLI error = %v", err) + } + + // Verify response structure + if resp.Content == "" { + t.Error("Content is empty") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage == nil { + t.Error("Usage should not be nil from real CLI") + } else { + if resp.Usage.PromptTokens == 0 { + t.Error("PromptTokens should be > 0") + } + if resp.Usage.CompletionTokens == 0 { + t.Error("CompletionTokens should be > 0") + } + t.Logf("Usage: prompt=%d, completion=%d, total=%d", + resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) + } + + t.Logf("Response content: %q", resp.Content) + + // Loose check - should contain "pong" somewhere (model might capitalize or add punctuation) + if !strings.Contains(strings.ToLower(resp.Content), "pong") { + t.Errorf("Content = %q, expected to contain 'pong'", resp.Content) + } +} + +func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) { + if _, err := exec.LookPath("claude"); err != nil { + t.Skip("claude CLI not found in PATH") + } + + p := NewClaudeCliProvider(t.TempDir()) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + resp, err := p.Chat(ctx, []Message{ + {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, + {Role: "user", Content: "What is 2+2?"}, + }, nil, "", nil) + + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + t.Logf("Response: %q", resp.Content) + + if !strings.Contains(resp.Content, "4") { + t.Errorf("Content = %q, expected to contain '4'", resp.Content) + } +} + +func TestIntegration_RealClaudeCLI_ParsesRealJSON(t *testing.T) { + if _, err := exec.LookPath("claude"); err != nil { + t.Skip("claude CLI not found in PATH") + } + + // Run claude directly and verify our parser handles real output + cmd := exec.Command("claude", "-p", "--output-format", "json", + "--dangerously-skip-permissions", "--no-chrome", "--no-session-persistence", "-") + cmd.Stdin = strings.NewReader("Say hi") + cmd.Dir = t.TempDir() + + output, err := cmd.Output() + if err != nil { + t.Fatalf("claude CLI failed: %v", err) + } + + t.Logf("Raw CLI output: %s", string(output)) + + // Verify our parser can handle real output + p := NewClaudeCliProvider("") + resp, err := p.parseClaudeCliResponse(string(output)) + if err != nil { + t.Fatalf("parseClaudeCliResponse() failed on real CLI output: %v", err) + } + + if resp.Content == "" { + t.Error("parsed Content is empty") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want stop", resp.FinishReason) + } + if resp.Usage == nil { + t.Error("Usage should not be nil") + } + + t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage) +} diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go new file mode 100644 index 000000000..063530deb --- /dev/null +++ b/pkg/providers/claude_cli_provider_test.go @@ -0,0 +1,981 @@ +package providers + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// --- Compile-time interface check --- + +var _ LLMProvider = (*ClaudeCliProvider)(nil) + +// --- Helper: create mock CLI scripts --- + +// createMockCLI creates a temporary script that simulates the claude CLI. +// Uses files for stdout/stderr to avoid shell quoting issues with JSON. +func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } + + dir := t.TempDir() + + if stdout != "" { + if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil { + t.Fatal(err) + } + } + if stderr != "" { + if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil { + t.Fatal(err) + } + } + + var sb strings.Builder + sb.WriteString("#!/bin/sh\n") + if stderr != "" { + sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir)) + } + if stdout != "" { + sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir)) + } + sb.WriteString(fmt.Sprintf("exit %d\n", exitCode)) + + script := filepath.Join(dir, "claude") + if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil { + t.Fatal(err) + } + return script +} + +// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests). +func createSlowMockCLI(t *testing.T, sleepSeconds int) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } + + dir := t.TempDir() + script := filepath.Join(dir, "claude") + content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds) + if err := os.WriteFile(script, []byte(content), 0755); err != nil { + t.Fatal(err) + } + return script +} + +// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON. +func createArgCaptureCLI(t *testing.T, argsFile string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } + + dir := t.TempDir() + script := filepath.Join(dir, "claude") + content := fmt.Sprintf(`#!/bin/sh +echo "$@" > '%s' +cat <<'EOFMOCK' +{"type":"result","result":"ok","session_id":"test"} +EOFMOCK +`, argsFile) + if err := os.WriteFile(script, []byte(content), 0755); err != nil { + t.Fatal(err) + } + return script +} + +// --- Constructor tests --- + +func TestNewClaudeCliProvider(t *testing.T) { + p := NewClaudeCliProvider("/test/workspace") + if p == nil { + t.Fatal("NewClaudeCliProvider returned nil") + } + if p.workspace != "/test/workspace" { + t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace") + } + if p.command != "claude" { + t.Errorf("command = %q, want %q", p.command, "claude") + } +} + +func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) { + p := NewClaudeCliProvider("") + if p.workspace != "" { + t.Errorf("workspace = %q, want empty", p.workspace) + } +} + +// --- GetDefaultModel tests --- + +func TestClaudeCliProvider_GetDefaultModel(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + if got := p.GetDefaultModel(); got != "claude-code" { + t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code") + } +} + +// --- Chat() tests --- + +func TestChat_Success(t *testing.T) { + mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}` + script := createMockCLI(t, mockJSON, "", 0) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + resp, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.Content != "Hello from mock!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if len(resp.ToolCalls) != 0 { + t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls)) + } + if resp.Usage == nil { + t.Fatal("Usage should not be nil") + } + if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0 + t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens) + } + if resp.Usage.CompletionTokens != 5 { + t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) + } + if resp.Usage.TotalTokens != 115 { // 110 + 5 + t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens) + } +} + +func TestChat_IsErrorResponse(t *testing.T) { + mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}` + script := createMockCLI(t, mockJSON, "", 0) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + + if err == nil { + t.Fatal("Chat() expected error when is_error=true") + } + if !strings.Contains(err.Error(), "Rate limit exceeded") { + t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error()) + } +} + +func TestChat_WithToolCallsInResponse(t *testing.T) { + mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` + script := createMockCLI(t, mockJSON, "", 0) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + resp, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "What's the weather?"}, + }, nil, "", nil) + + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if resp.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].Name != "get_weather" { + t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather") + } + if resp.ToolCalls[0].Arguments["location"] != "NYC" { + t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"]) + } +} + +func TestChat_StderrError(t *testing.T) { + script := createMockCLI(t, "", "Error: rate limited", 1) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + + if err == nil { + t.Fatal("Chat() expected error") + } + if !strings.Contains(err.Error(), "rate limited") { + t.Errorf("error = %q, want to contain 'rate limited'", err.Error()) + } +} + +func TestChat_NonZeroExitNoStderr(t *testing.T) { + script := createMockCLI(t, "", "", 1) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + + if err == nil { + t.Fatal("Chat() expected error for non-zero exit") + } + if !strings.Contains(err.Error(), "claude cli error") { + t.Errorf("error = %q, want to contain 'claude cli error'", err.Error()) + } +} + +func TestChat_CommandNotFound(t *testing.T) { + p := NewClaudeCliProvider(t.TempDir()) + p.command = "/nonexistent/claude-binary-that-does-not-exist" + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + + if err == nil { + t.Fatal("Chat() expected error for missing command") + } +} + +func TestChat_InvalidResponseJSON(t *testing.T) { + script := createMockCLI(t, "not valid json at all", "", 0) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + + if err == nil { + t.Fatal("Chat() expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "failed to parse claude cli response") { + t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) + } +} + +func TestChat_ContextCancellation(t *testing.T) { + script := createSlowMockCLI(t, 2) // sleep 2s + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := p.Chat(ctx, []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("Chat() expected error on context cancellation") + } + // Should fail well before the full 2s sleep completes + if elapsed > 3*time.Second { + t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed) + } +} + +func TestChat_PassesSystemPromptFlag(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + script := createArgCaptureCLI(t, argsFile) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "system", Content: "Be helpful."}, + {Role: "user", Content: "Hi"}, + }, nil, "", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + argsBytes, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("failed to read args file: %v", err) + } + args := string(argsBytes) + if !strings.Contains(args, "--system-prompt") { + t.Errorf("CLI args missing --system-prompt, got: %s", args) + } +} + +func TestChat_PassesModelFlag(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + script := createArgCaptureCLI(t, argsFile) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hi"}, + }, nil, "claude-sonnet-4-5-20250929", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + argsBytes, _ := os.ReadFile(argsFile) + args := string(argsBytes) + if !strings.Contains(args, "--model") { + t.Errorf("CLI args missing --model, got: %s", args) + } + if !strings.Contains(args, "claude-sonnet-4-5-20250929") { + t.Errorf("CLI args missing model name, got: %s", args) + } +} + +func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + script := createArgCaptureCLI(t, argsFile) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hi"}, + }, nil, "claude-code", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + argsBytes, _ := os.ReadFile(argsFile) + args := string(argsBytes) + if strings.Contains(args, "--model") { + t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args) + } +} + +func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args.txt") + script := createArgCaptureCLI(t, argsFile) + + p := NewClaudeCliProvider(t.TempDir()) + p.command = script + + _, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hi"}, + }, nil, "", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + argsBytes, _ := os.ReadFile(argsFile) + args := string(argsBytes) + if strings.Contains(args, "--model") { + t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args) + } +} + +func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { + mockJSON := `{"type":"result","result":"ok","session_id":"s"}` + script := createMockCLI(t, mockJSON, "", 0) + + p := NewClaudeCliProvider("") + p.command = script + + resp, err := p.Chat(context.Background(), []Message{ + {Role: "user", Content: "Hello"}, + }, nil, "", nil) + + if err != nil { + t.Fatalf("Chat() with empty workspace error = %v", err) + } + if resp.Content != "ok" { + t.Errorf("Content = %q, want %q", resp.Content, "ok") + } +} + +// --- CreateProvider factory tests --- + +func TestCreateProvider_ClaudeCli(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "claude-cli" + cfg.Agents.Defaults.Workspace = "/test/ws" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-cli) error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) + } + if cliProvider.workspace != "/test/ws" { + t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws") + } +} + +func TestCreateProvider_ClaudeCode(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "claude-code" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-code) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCodec(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "claudecode" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claudecode) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "claude-cli" + cfg.Agents.Defaults.Workspace = "" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("returned %T, want *ClaudeCliProvider", provider) + } + if cliProvider.workspace != "." { + t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".") + } +} + +// --- messagesToPrompt tests --- + +func TestMessagesToPrompt_SingleUser(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "Hello"}, + } + got := p.messagesToPrompt(messages) + want := "Hello" + if got != want { + t.Errorf("messagesToPrompt() = %q, want %q", got, want) + } +} + +func TestMessagesToPrompt_Conversation(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "Hi"}, + {Role: "assistant", Content: "Hello!"}, + {Role: "user", Content: "How are you?"}, + } + got := p.messagesToPrompt(messages) + want := "User: Hi\nAssistant: Hello!\nUser: How are you?" + if got != want { + t.Errorf("messagesToPrompt() = %q, want %q", got, want) + } +} + +func TestMessagesToPrompt_WithSystemMessage(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hello"}, + } + got := p.messagesToPrompt(messages) + want := "Hello" + if got != want { + t.Errorf("messagesToPrompt() = %q, want %q", got, want) + } +} + +func TestMessagesToPrompt_WithToolResults(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "What's the weather?"}, + {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"}, + } + got := p.messagesToPrompt(messages) + if !strings.Contains(got, "[Tool Result for call_123]") { + t.Errorf("messagesToPrompt() missing tool result marker, got %q", got) + } + if !strings.Contains(got, `{"temp": 72}`) { + t.Errorf("messagesToPrompt() missing tool result content, got %q", got) + } +} + +func TestMessagesToPrompt_EmptyMessages(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + got := p.messagesToPrompt(nil) + if got != "" { + t.Errorf("messagesToPrompt(nil) = %q, want empty", got) + } +} + +func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "system", Content: "System 1"}, + {Role: "system", Content: "System 2"}, + } + got := p.messagesToPrompt(messages) + if got != "" { + t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got) + } +} + +// --- buildSystemPrompt tests --- + +func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "user", Content: "Hi"}, + } + got := p.buildSystemPrompt(messages, nil) + if got != "" { + t.Errorf("buildSystemPrompt() = %q, want empty", got) + } +} + +func TestBuildSystemPrompt_SystemOnly(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hi"}, + } + got := p.buildSystemPrompt(messages, nil) + if got != "You are helpful." { + t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.") + } +} + +func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "system", Content: "Be concise."}, + {Role: "user", Content: "Hi"}, + } + got := p.buildSystemPrompt(messages, nil) + if !strings.Contains(got, "You are helpful.") { + t.Error("missing first system message") + } + if !strings.Contains(got, "Be concise.") { + t.Error("missing second system message") + } + // Should be joined with double newline + want := "You are helpful.\n\nBe concise." + if got != want { + t.Errorf("buildSystemPrompt() = %q, want %q", got, want) + } +} + +func TestBuildSystemPrompt_WithTools(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "system", Content: "You are helpful."}, + } + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get weather for a location", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "location": map[string]interface{}{"type": "string"}, + }, + }, + }, + }, + } + got := p.buildSystemPrompt(messages, tools) + if !strings.Contains(got, "You are helpful.") { + t.Error("buildSystemPrompt() missing system message") + } + if !strings.Contains(got, "get_weather") { + t.Error("buildSystemPrompt() missing tool definition") + } + if !strings.Contains(got, "Available Tools") { + t.Error("buildSystemPrompt() missing tools header") + } +} + +func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "test_tool", + Description: "A test tool", + }, + }, + } + got := p.buildSystemPrompt(nil, tools) + if !strings.Contains(got, "test_tool") { + t.Error("should include tool definitions even without system messages") + } +} + +// --- buildToolsPrompt tests --- + +func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + tools := []ToolDefinition{ + {Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}}, + {Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}}, + } + got := p.buildToolsPrompt(tools) + if strings.Contains(got, "skip_me") { + t.Error("buildToolsPrompt() should skip non-function tools") + } + if !strings.Contains(got, "include_me") { + t.Error("buildToolsPrompt() should include function tools") + } +} + +func TestBuildToolsPrompt_NoDescription(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}}, + } + got := p.buildToolsPrompt(tools) + if !strings.Contains(got, "bare_tool") { + t.Error("should include tool name") + } + if strings.Contains(got, "Description:") { + t.Error("should not include Description: line when empty") + } +} + +func TestBuildToolsPrompt_NoParameters(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{ + Name: "no_params_tool", + Description: "A tool with no parameters", + }}, + } + got := p.buildToolsPrompt(tools) + if strings.Contains(got, "Parameters:") { + t.Error("should not include Parameters: section when nil") + } +} + +// --- parseClaudeCliResponse tests --- + +func TestParseClaudeCliResponse_TextOnly(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` + + resp, err := p.parseClaudeCliResponse(output) + if err != nil { + t.Fatalf("parseClaudeCliResponse() error = %v", err) + } + if resp.Content != "Hello, world!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if len(resp.ToolCalls) != 0 { + t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls)) + } + if resp.Usage == nil { + t.Fatal("Usage should not be nil") + } + if resp.Usage.PromptTokens != 10 { + t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens) + } + if resp.Usage.CompletionTokens != 20 { + t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) + } +} + +func TestParseClaudeCliResponse_EmptyResult(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}` + + resp, err := p.parseClaudeCliResponse(output) + if err != nil { + t.Fatalf("error = %v", err) + } + if resp.Content != "" { + t.Errorf("Content = %q, want empty", resp.Content) + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } +} + +func TestParseClaudeCliResponse_IsError(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}` + + _, err := p.parseClaudeCliResponse(output) + if err == nil { + t.Fatal("expected error when is_error=true") + } + if !strings.Contains(err.Error(), "Something went wrong") { + t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error()) + } +} + +func TestParseClaudeCliResponse_NoUsage(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}` + + resp, err := p.parseClaudeCliResponse(output) + if err != nil { + t.Fatalf("error = %v", err) + } + if resp.Usage != nil { + t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage) + } +} + +func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + _, err := p.parseClaudeCliResponse("not json") + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "failed to parse claude cli response") { + t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) + } +} + +func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}` + + resp, err := p.parseClaudeCliResponse(output) + if err != nil { + t.Fatalf("error = %v", err) + } + if resp.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("ToolCalls = %d, want 1", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.Name != "get_weather" { + t.Errorf("Name = %q, want %q", tc.Name, "get_weather") + } + if tc.Function == nil { + t.Fatal("Function is nil") + } + if tc.Function.Name != "get_weather" { + t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather") + } + if tc.Arguments["location"] != "Tokyo" { + t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"]) + } + if strings.Contains(resp.Content, "tool_calls") { + t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content) + } + if resp.Content != "Let me check." { + t.Errorf("Content = %q, want %q", resp.Content, "Let me check.") + } +} + +func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}` + + resp, err := p.parseClaudeCliResponse(output) + if err != nil { + t.Fatalf("error = %v", err) + } + if resp.Content != "hello" { + t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello") + } +} + +// --- extractToolCalls tests --- + +func TestExtractToolCalls_NoToolCalls(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + got := p.extractToolCalls("Just a regular response.") + if len(got) != 0 { + t.Errorf("extractToolCalls() = %d, want 0", len(got)) + } +} + +func TestExtractToolCalls_WithToolCalls(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + text := `Here's the result: +{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}` + + got := p.extractToolCalls(text) + if len(got) != 1 { + t.Fatalf("extractToolCalls() = %d, want 1", len(got)) + } + if got[0].ID != "call_1" { + t.Errorf("ID = %q, want %q", got[0].ID, "call_1") + } + if got[0].Name != "test" { + t.Errorf("Name = %q, want %q", got[0].Name, "test") + } + if got[0].Type != "function" { + t.Errorf("Type = %q, want %q", got[0].Type, "function") + } +} + +func TestExtractToolCalls_InvalidJSON(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + got := p.extractToolCalls(`{"tool_calls":invalid}`) + if len(got) != 0 { + t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got)) + } +} + +func TestExtractToolCalls_MultipleToolCalls(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}` + + got := p.extractToolCalls(text) + if len(got) != 2 { + t.Fatalf("extractToolCalls() = %d, want 2", len(got)) + } + if got[0].Name != "read_file" { + t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file") + } + if got[1].Name != "write_file" { + t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file") + } + // Verify arguments were parsed + if got[0].Arguments["path"] != "/tmp/test" { + t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"]) + } + if got[1].Arguments["content"] != "hello" { + t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"]) + } +} + +func TestExtractToolCalls_UnmatchedBrace(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`) + if len(got) != 0 { + t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got)) + } +} + +func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}` + + got := p.extractToolCalls(text) + if len(got) != 1 { + t.Fatalf("len = %d, want 1", len(got)) + } + // Verify different argument types + if got[0].Arguments["num"] != float64(42) { + t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"]) + } + if got[0].Arguments["flag"] != true { + t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"]) + } + if got[0].Arguments["name"] != "test" { + t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) + } + // Verify raw arguments string is preserved in FunctionCall + if got[0].Function.Arguments == "" { + t.Error("Function.Arguments should contain raw JSON string") + } +} + +// --- stripToolCallsJSON tests --- + +func TestStripToolCallsJSON(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + text := `Let me check the weather. +{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]} +Done.` + + got := p.stripToolCallsJSON(text) + if strings.Contains(got, "tool_calls") { + t.Errorf("should remove tool_calls JSON, got %q", got) + } + if !strings.Contains(got, "Let me check the weather.") { + t.Errorf("should keep text before, got %q", got) + } + if !strings.Contains(got, "Done.") { + t.Errorf("should keep text after, got %q", got) + } +} + +func TestStripToolCallsJSON_NoToolCalls(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + text := "Just regular text." + got := p.stripToolCallsJSON(text) + if got != text { + t.Errorf("stripToolCallsJSON() = %q, want %q", got, text) + } +} + +func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}` + got := p.stripToolCallsJSON(text) + if got != "" { + t.Errorf("stripToolCallsJSON() = %q, want empty", got) + } +} + +// --- findMatchingBrace tests --- + +func TestFindMatchingBrace(t *testing.T) { + tests := []struct { + text string + pos int + want int + }{ + {`{"a":1}`, 0, 7}, + {`{"a":{"b":2}}`, 0, 13}, + {`text {"a":1} more`, 5, 12}, + {`{unclosed`, 0, 0}, // no match returns pos + {`{}`, 0, 2}, // empty object + {`{{{}}}`, 0, 6}, // deeply nested + {`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher) + } + for _, tt := range tests { + got := findMatchingBrace(tt.text, tt.pos) + if got != tt.want { + t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want) + } + } +} diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index ae6aca96d..3ca54d5a3 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -2,200 +2,58 @@ package providers import ( "context" - "encoding/json" "fmt" - "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/option" - "github.com/sipeed/picoclaw/pkg/auth" + anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) type ClaudeProvider struct { - client *anthropic.Client - tokenSource func() (string, error) + delegate *anthropicprovider.Provider } func NewClaudeProvider(token string) *ClaudeProvider { - client := anthropic.NewClient( - option.WithAuthToken(token), - option.WithBaseURL("https://api.anthropic.com"), - ) - return &ClaudeProvider{client: &client} + return &ClaudeProvider{ + delegate: anthropicprovider.NewProvider(token), + } +} + +func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase), + } } func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { - p := NewClaudeProvider(token) - p.tokenSource = tokenSource - return p + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource), + } +} + +func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase), + } +} + +func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider { + return &ClaudeProvider{delegate: delegate} } func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - var opts []option.RequestOption - if p.tokenSource != nil { - tok, err := p.tokenSource() - if err != nil { - return nil, fmt.Errorf("refreshing token: %w", err) - } - opts = append(opts, option.WithAuthToken(tok)) - } - - params, err := buildClaudeParams(messages, tools, model, options) + resp, err := p.delegate.Chat(ctx, messages, tools, model, options) if err != nil { return nil, err } - - resp, err := p.client.Messages.New(ctx, params, opts...) - if err != nil { - return nil, fmt.Errorf("claude API call: %w", err) - } - - return parseClaudeResponse(resp), nil + return resp, nil } func (p *ClaudeProvider) GetDefaultModel() string { - return "claude-sonnet-4-5-20250929" -} - -func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { - var system []anthropic.TextBlockParam - var anthropicMessages []anthropic.MessageParam - - for _, msg := range messages { - switch msg.Role { - case "system": - system = append(system, anthropic.TextBlockParam{Text: msg.Content}) - case "user": - if msg.ToolCallID != "" { - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), - ) - } else { - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), - ) - } - case "assistant": - if len(msg.ToolCalls) > 0 { - var blocks []anthropic.ContentBlockParamUnion - if msg.Content != "" { - blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) - } - for _, tc := range msg.ToolCalls { - blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) - } - anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) - } else { - anthropicMessages = append(anthropicMessages, - anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), - ) - } - case "tool": - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), - ) - } - } - - maxTokens := int64(4096) - if mt, ok := options["max_tokens"].(int); ok { - maxTokens = int64(mt) - } - - params := anthropic.MessageNewParams{ - Model: anthropic.Model(model), - Messages: anthropicMessages, - MaxTokens: maxTokens, - } - - if len(system) > 0 { - params.System = system - } - - if temp, ok := options["temperature"].(float64); ok { - params.Temperature = anthropic.Float(temp) - } - - if len(tools) > 0 { - params.Tools = translateToolsForClaude(tools) - } - - return params, nil -} - -func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam { - result := make([]anthropic.ToolUnionParam, 0, len(tools)) - for _, t := range tools { - tool := anthropic.ToolParam{ - Name: t.Function.Name, - InputSchema: anthropic.ToolInputSchemaParam{ - Properties: t.Function.Parameters["properties"], - }, - } - if desc := t.Function.Description; desc != "" { - tool.Description = anthropic.String(desc) - } - if req, ok := t.Function.Parameters["required"].([]interface{}); ok { - required := make([]string, 0, len(req)) - for _, r := range req { - if s, ok := r.(string); ok { - required = append(required, s) - } - } - tool.InputSchema.Required = required - } - result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) - } - return result -} - -func parseClaudeResponse(resp *anthropic.Message) *LLMResponse { - var content string - var toolCalls []ToolCall - - for _, block := range resp.Content { - switch block.Type { - case "text": - tb := block.AsText() - content += tb.Text - case "tool_use": - tu := block.AsToolUse() - var args map[string]interface{} - if err := json.Unmarshal(tu.Input, &args); err != nil { - args = map[string]interface{}{"raw": string(tu.Input)} - } - toolCalls = append(toolCalls, ToolCall{ - ID: tu.ID, - Name: tu.Name, - Arguments: args, - }) - } - } - - finishReason := "stop" - switch resp.StopReason { - case anthropic.StopReasonToolUse: - finishReason = "tool_calls" - case anthropic.StopReasonMaxTokens: - finishReason = "length" - case anthropic.StopReasonEndTurn: - finishReason = "stop" - } - - return &LLMResponse{ - Content: content, - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: &UsageInfo{ - PromptTokens: int(resp.Usage.InputTokens), - CompletionTokens: int(resp.Usage.OutputTokens), - TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), - }, - } + return p.delegate.GetDefaultModel() } func createClaudeTokenSource() func() (string, error) { return func() (string, error) { - cred, err := auth.GetCredential("anthropic") + cred, err := getCredential("anthropic") if err != nil { return "", fmt.Errorf("loading auth credentials: %w", err) } diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go index bbad2d269..13bbde1fc 100644 --- a/pkg/providers/claude_provider_test.go +++ b/pkg/providers/claude_provider_test.go @@ -8,140 +8,9 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) -func TestBuildClaudeParams_BasicMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "Hello"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{ - "max_tokens": 1024, - }) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if string(params.Model) != "claude-sonnet-4-5-20250929" { - t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929") - } - if params.MaxTokens != 1024 { - t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens) - } - if len(params.Messages) != 1 { - t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) - } -} - -func TestBuildClaudeParams_SystemMessage(t *testing.T) { - messages := []Message{ - {Role: "system", Content: "You are helpful"}, - {Role: "user", Content: "Hi"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.System) != 1 { - t.Fatalf("len(System) = %d, want 1", len(params.System)) - } - if params.System[0].Text != "You are helpful" { - t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful") - } - if len(params.Messages) != 1 { - t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) - } -} - -func TestBuildClaudeParams_ToolCallMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "What's the weather?"}, - { - Role: "assistant", - Content: "", - ToolCalls: []ToolCall{ - { - ID: "call_1", - Name: "get_weather", - Arguments: map[string]interface{}{"city": "SF"}, - }, - }, - }, - {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.Messages) != 3 { - t.Fatalf("len(Messages) = %d, want 3", len(params.Messages)) - } -} - -func TestBuildClaudeParams_WithTools(t *testing.T) { - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get weather for a city", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, - }, - "required": []interface{}{"city"}, - }, - }, - }, - } - params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.Tools) != 1 { - t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) - } -} - -func TestParseClaudeResponse_TextOnly(t *testing.T) { - resp := &anthropic.Message{ - Content: []anthropic.ContentBlockUnion{}, - Usage: anthropic.Usage{ - InputTokens: 10, - OutputTokens: 20, - }, - } - result := parseClaudeResponse(resp) - if result.Usage.PromptTokens != 10 { - t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) - } - if result.Usage.CompletionTokens != 20 { - t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens) - } - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") - } -} - -func TestParseClaudeResponse_StopReasons(t *testing.T) { - tests := []struct { - stopReason anthropic.StopReason - want string - }{ - {anthropic.StopReasonEndTurn, "stop"}, - {anthropic.StopReasonMaxTokens, "length"}, - {anthropic.StopReasonToolUse, "tool_calls"}, - } - for _, tt := range tests { - resp := &anthropic.Message{ - StopReason: tt.stopReason, - } - result := parseClaudeResponse(resp) - if result.FinishReason != tt.want { - t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want) - } - } -} - func TestClaudeProvider_ChatRoundTrip(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/messages" { @@ -175,8 +44,8 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) { })) defer server.Close() - provider := NewClaudeProvider("test-token") - provider.client = createAnthropicTestClient(server.URL, "test-token") + delegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) + provider := newClaudeProviderWithDelegate(delegate) messages := []Message{{Role: "user", Content: "Hello"}} resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024}) diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go new file mode 100644 index 000000000..7ad39ce8e --- /dev/null +++ b/pkg/providers/codex_cli_credentials.go @@ -0,0 +1,79 @@ +package providers + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// CodexCliAuth represents the ~/.codex/auth.json file structure. +type CodexCliAuth struct { + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id"` + } `json:"tokens"` +} + +// ReadCodexCliCredentials reads OAuth tokens from the Codex CLI's auth.json file. +// Expiry is estimated as file modification time + 1 hour (same approach as moltbot). +func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) { + authPath, err := resolveCodexAuthPath() + if err != nil { + return "", "", time.Time{}, err + } + + data, err := os.ReadFile(authPath) + if err != nil { + return "", "", time.Time{}, fmt.Errorf("reading %s: %w", authPath, err) + } + + var auth CodexCliAuth + if err := json.Unmarshal(data, &auth); err != nil { + return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err) + } + + if auth.Tokens.AccessToken == "" { + return "", "", time.Time{}, fmt.Errorf("no access_token in %s", authPath) + } + + stat, err := os.Stat(authPath) + if err != nil { + expiresAt = time.Now().Add(time.Hour) + } else { + expiresAt = stat.ModTime().Add(time.Hour) + } + + return auth.Tokens.AccessToken, auth.Tokens.AccountID, expiresAt, nil +} + +// CreateCodexCliTokenSource creates a token source that reads from ~/.codex/auth.json. +// This allows the existing CodexProvider to reuse Codex CLI credentials. +func CreateCodexCliTokenSource() func() (string, string, error) { + return func() (string, string, error) { + token, accountID, expiresAt, err := ReadCodexCliCredentials() + if err != nil { + return "", "", fmt.Errorf("reading codex cli credentials: %w", err) + } + + if time.Now().After(expiresAt) { + return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login") + } + + return token, accountID, nil + } +} + +func resolveCodexAuthPath() (string, error) { + codexHome := os.Getenv("CODEX_HOME") + if codexHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home dir: %w", err) + } + codexHome = filepath.Join(home, ".codex") + } + return filepath.Join(codexHome, "auth.json"), nil +} diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go new file mode 100644 index 000000000..3267f2d16 --- /dev/null +++ b/pkg/providers/codex_cli_credentials_test.go @@ -0,0 +1,181 @@ +package providers + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestReadCodexCliCredentials_Valid(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "auth.json") + + authJSON := `{ + "tokens": { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "account_id": "org-test123" + } + }` + if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("CODEX_HOME", tmpDir) + + token, accountID, expiresAt, err := ReadCodexCliCredentials() + if err != nil { + t.Fatalf("ReadCodexCliCredentials() error: %v", err) + } + if token != "test-access-token" { + t.Errorf("token = %q, want %q", token, "test-access-token") + } + if accountID != "org-test123" { + t.Errorf("accountID = %q, want %q", accountID, "org-test123") + } + // Expiry should be within ~1 hour from now (file was just written) + if expiresAt.Before(time.Now()) { + t.Errorf("expiresAt = %v, should be in the future", expiresAt) + } + if expiresAt.After(time.Now().Add(2 * time.Hour)) { + t.Errorf("expiresAt = %v, should be within ~1 hour", expiresAt) + } +} + +func TestReadCodexCliCredentials_MissingFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("CODEX_HOME", tmpDir) + + _, _, _, err := ReadCodexCliCredentials() + if err == nil { + t.Fatal("expected error for missing auth.json") + } +} + +func TestReadCodexCliCredentials_EmptyToken(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "auth.json") + + authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}` + if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("CODEX_HOME", tmpDir) + + _, _, _, err := ReadCodexCliCredentials() + if err == nil { + t.Fatal("expected error for empty access_token") + } +} + +func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "auth.json") + + if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("CODEX_HOME", tmpDir) + + _, _, _, err := ReadCodexCliCredentials() + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestReadCodexCliCredentials_NoAccountID(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "auth.json") + + authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}` + if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("CODEX_HOME", tmpDir) + + token, accountID, _, err := ReadCodexCliCredentials() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "tok123" { + t.Errorf("token = %q, want %q", token, "tok123") + } + if accountID != "" { + t.Errorf("accountID = %q, want empty", accountID) + } +} + +func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) { + tmpDir := t.TempDir() + customDir := filepath.Join(tmpDir, "custom-codex") + if err := os.MkdirAll(customDir, 0755); err != nil { + t.Fatal(err) + } + + authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}` + if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("CODEX_HOME", customDir) + + token, _, _, err := ReadCodexCliCredentials() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "custom-token" { + t.Errorf("token = %q, want %q", token, "custom-token") + } +} + +func TestCreateCodexCliTokenSource_Valid(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "auth.json") + + authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}` + if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("CODEX_HOME", tmpDir) + + source := CreateCodexCliTokenSource() + token, accountID, err := source() + if err != nil { + t.Fatalf("token source error: %v", err) + } + if token != "fresh-token" { + t.Errorf("token = %q, want %q", token, "fresh-token") + } + if accountID != "acc" { + t.Errorf("accountID = %q, want %q", accountID, "acc") + } +} + +func TestCreateCodexCliTokenSource_Expired(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "auth.json") + + authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}` + if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { + t.Fatal(err) + } + + // Set file modification time to 2 hours ago + oldTime := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(authPath, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + t.Setenv("CODEX_HOME", tmpDir) + + source := CreateCodexCliTokenSource() + _, _, err := source() + if err == nil { + t.Fatal("expected error for expired credentials") + } +} diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go new file mode 100644 index 000000000..8886406b4 --- /dev/null +++ b/pkg/providers/codex_cli_provider.go @@ -0,0 +1,251 @@ +package providers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. +type CodexCliProvider struct { + command string + workspace string +} + +// NewCodexCliProvider creates a new Codex CLI provider. +func NewCodexCliProvider(workspace string) *CodexCliProvider { + return &CodexCliProvider{ + command: "codex", + workspace: workspace, + } +} + +// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode. +func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + if p.command == "" { + return nil, fmt.Errorf("codex command not configured") + } + + prompt := p.buildPrompt(messages, tools) + + args := []string{ + "exec", + "--json", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + "--color", "never", + } + if model != "" && model != "codex-cli" { + args = append(args, "-m", model) + } + if p.workspace != "" { + args = append(args, "-C", p.workspace) + } + args = append(args, "-") // read prompt from stdin + + cmd := exec.CommandContext(ctx, p.command, args...) + cmd.Stdin = bytes.NewReader([]byte(prompt)) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + // Parse JSONL from stdout even if exit code is non-zero, + // because codex writes diagnostic noise to stderr (e.g. rollout errors) + // but still produces valid JSONL output. + if stdoutStr := stdout.String(); stdoutStr != "" { + resp, parseErr := p.parseJSONLEvents(stdoutStr) + if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) { + return resp, nil + } + } + + if err != nil { + if ctx.Err() == context.Canceled { + return nil, ctx.Err() + } + if stderrStr := stderr.String(); stderrStr != "" { + return nil, fmt.Errorf("codex cli error: %s", stderrStr) + } + return nil, fmt.Errorf("codex cli error: %w", err) + } + + return p.parseJSONLEvents(stdout.String()) +} + +// GetDefaultModel returns the default model identifier. +func (p *CodexCliProvider) GetDefaultModel() string { + return "codex-cli" +} + +// buildPrompt converts messages to a prompt string for the Codex CLI. +// System messages are prepended as instructions since Codex CLI has no --system-prompt flag. +func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string { + var systemParts []string + var conversationParts []string + + for _, msg := range messages { + switch msg.Role { + case "system": + systemParts = append(systemParts, msg.Content) + case "user": + conversationParts = append(conversationParts, msg.Content) + case "assistant": + conversationParts = append(conversationParts, "Assistant: "+msg.Content) + case "tool": + conversationParts = append(conversationParts, + fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) + } + } + + var sb strings.Builder + + if len(systemParts) > 0 { + sb.WriteString("## System Instructions\n\n") + sb.WriteString(strings.Join(systemParts, "\n\n")) + sb.WriteString("\n\n## Task\n\n") + } + + if len(tools) > 0 { + sb.WriteString(p.buildToolsPrompt(tools)) + sb.WriteString("\n\n") + } + + // Simplify single user message (no prefix) + if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 { + return conversationParts[0] + } + + sb.WriteString(strings.Join(conversationParts, "\n")) + return sb.String() +} + +// buildToolsPrompt creates a tool definitions section for the prompt. +func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { + var sb strings.Builder + + sb.WriteString("## Available Tools\n\n") + sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") + sb.WriteString("```json\n") + sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) + sb.WriteString("\n```\n\n") + sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") + sb.WriteString("### Tool Definitions:\n\n") + + for _, tool := range tools { + if tool.Type != "function" { + continue + } + sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) + if tool.Function.Description != "" { + sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) + } + if len(tool.Function.Parameters) > 0 { + paramsJSON, _ := json.Marshal(tool.Function.Parameters) + sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) + } + sb.WriteString("\n") + } + + return sb.String() +} + +// codexEvent represents a single JSONL event from `codex exec --json`. +type codexEvent struct { + Type string `json:"type"` + ThreadID string `json:"thread_id,omitempty"` + Message string `json:"message,omitempty"` + Item *codexEventItem `json:"item,omitempty"` + Usage *codexUsage `json:"usage,omitempty"` + Error *codexEventErr `json:"error,omitempty"` +} + +type codexEventItem struct { + ID string `json:"id"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + Command string `json:"command,omitempty"` + Status string `json:"status,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Output string `json:"output,omitempty"` +} + +type codexUsage struct { + InputTokens int `json:"input_tokens"` + CachedInputTokens int `json:"cached_input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type codexEventErr struct { + Message string `json:"message"` +} + +// parseJSONLEvents processes the JSONL output from codex exec --json. +func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) { + var contentParts []string + var usage *UsageInfo + var lastError string + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var event codexEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + continue // skip malformed lines + } + + switch event.Type { + case "item.completed": + if event.Item != nil && event.Item.Type == "agent_message" && event.Item.Text != "" { + contentParts = append(contentParts, event.Item.Text) + } + case "turn.completed": + if event.Usage != nil { + promptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens + usage = &UsageInfo{ + PromptTokens: promptTokens, + CompletionTokens: event.Usage.OutputTokens, + TotalTokens: promptTokens + event.Usage.OutputTokens, + } + } + case "error": + lastError = event.Message + case "turn.failed": + if event.Error != nil { + lastError = event.Error.Message + } + } + } + + if lastError != "" && len(contentParts) == 0 { + return nil, fmt.Errorf("codex cli: %s", lastError) + } + + content := strings.Join(contentParts, "\n") + + // Extract tool calls from response text (same pattern as ClaudeCliProvider) + toolCalls := extractToolCallsFromText(content) + + finishReason := "stop" + if len(toolCalls) > 0 { + finishReason = "tool_calls" + content = stripToolCallsFromText(content) + } + + return &LLMResponse{ + Content: strings.TrimSpace(content), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/codex_cli_provider_integration_test.go new file mode 100644 index 000000000..0267c730f --- /dev/null +++ b/pkg/providers/codex_cli_provider_integration_test.go @@ -0,0 +1,119 @@ +//go:build integration + +package providers + +import ( + "context" + exec "os/exec" + "strings" + "testing" + "time" +) + +// TestIntegration_RealCodexCLI tests the CodexCliProvider with a real codex CLI. +// Run with: go test -tags=integration ./pkg/providers/... +func TestIntegration_RealCodexCLI(t *testing.T) { + path, err := exec.LookPath("codex") + if err != nil { + t.Skip("codex CLI not found in PATH, skipping integration test") + } + t.Logf("Using codex CLI at: %s", path) + + p := NewCodexCliProvider(t.TempDir()) + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + resp, err := p.Chat(ctx, []Message{ + {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, + }, nil, "", nil) + + if err != nil { + t.Fatalf("Chat() with real CLI error = %v", err) + } + + if resp.Content == "" { + t.Error("Content is empty") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage != nil { + t.Logf("Usage: prompt=%d, completion=%d, total=%d", + resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) + } + + t.Logf("Response content: %q", resp.Content) + + if !strings.Contains(strings.ToLower(resp.Content), "pong") { + t.Errorf("Content = %q, expected to contain 'pong'", resp.Content) + } +} + +func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) { + if _, err := exec.LookPath("codex"); err != nil { + t.Skip("codex CLI not found in PATH") + } + + p := NewCodexCliProvider(t.TempDir()) + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + resp, err := p.Chat(ctx, []Message{ + {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, + {Role: "user", Content: "What is 2+2?"}, + }, nil, "", nil) + + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + t.Logf("Response: %q", resp.Content) + + if !strings.Contains(resp.Content, "4") { + t.Errorf("Content = %q, expected to contain '4'", resp.Content) + } +} + +func TestIntegration_RealCodexCLI_ParsesRealJSONL(t *testing.T) { + if _, err := exec.LookPath("codex"); err != nil { + t.Skip("codex CLI not found in PATH") + } + + // Run codex directly and verify our parser handles real output + cmd := exec.Command("codex", "exec", + "--json", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + "--color", "never", + "-C", t.TempDir(), + "-") + cmd.Stdin = strings.NewReader("Say hi") + + output, err := cmd.Output() + if err != nil { + // codex may write diagnostic noise to stderr but still produce valid output + if len(output) == 0 { + t.Fatalf("codex CLI failed: %v", err) + } + } + + t.Logf("Raw CLI output (first 500 chars): %s", string(output[:min(len(output), 500)])) + + // Verify our parser can handle real output + p := NewCodexCliProvider("") + resp, err := p.parseJSONLEvents(string(output)) + if err != nil { + t.Fatalf("parseJSONLEvents() failed on real CLI output: %v", err) + } + + if resp.Content == "" { + t.Error("parsed Content is empty") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want stop", resp.FinishReason) + } + + t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage) +} diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go new file mode 100644 index 000000000..7e4e1bc15 --- /dev/null +++ b/pkg/providers/codex_cli_provider_test.go @@ -0,0 +1,585 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// --- JSONL Event Parsing Tests --- + +func TestParseJSONLEvents_AgentMessage(t *testing.T) { + p := &CodexCliProvider{} + events := `{"type":"thread.started","thread_id":"abc-123"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello from Codex!"}} +{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":20}}` + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("parseJSONLEvents() error: %v", err) + } + if resp.Content != "Hello from Codex!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello from Codex!") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage == nil { + t.Fatal("Usage should not be nil") + } + if resp.Usage.PromptTokens != 150 { + t.Errorf("PromptTokens = %d, want 150", resp.Usage.PromptTokens) + } + if resp.Usage.CompletionTokens != 20 { + t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) + } + if resp.Usage.TotalTokens != 170 { + t.Errorf("TotalTokens = %d, want 170", resp.Usage.TotalTokens) + } + if len(resp.ToolCalls) != 0 { + t.Errorf("ToolCalls should be empty, got %d", len(resp.ToolCalls)) + } +} + +func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) { + p := &CodexCliProvider{} + toolCallText := `Let me read that file. +{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}}]}` + // Build valid JSONL by marshaling the event + item := codexEvent{ + Type: "item.completed", + Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, + } + itemJSON, _ := json.Marshal(item) + usageEvt := `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":20}}` + events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + usageEvt + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("parseJSONLEvents() error: %v", err) + } + if resp.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("ToolCalls count = %d, want 1", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].Name != "read_file" { + t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") + } + if resp.ToolCalls[0].ID != "call_1" { + t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") + } + if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` { + t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments) + } + // Content should have the tool call JSON stripped + if strings.Contains(resp.Content, "tool_calls") { + t.Errorf("Content should not contain tool_calls JSON, got: %q", resp.Content) + } +} + +func TestParseJSONLEvents_MultipleToolCalls(t *testing.T) { + p := &CodexCliProvider{} + toolCallText := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"b.txt\",\"content\":\"hello\"}"}}]}` + item := codexEvent{ + Type: "item.completed", + Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, + } + itemJSON, _ := json.Marshal(item) + events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + `{"type":"turn.completed"}` + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("parseJSONLEvents() error: %v", err) + } + if len(resp.ToolCalls) != 2 { + t.Fatalf("ToolCalls count = %d, want 2", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].Name != "read_file" { + t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") + } + if resp.ToolCalls[1].Name != "write_file" { + t.Errorf("ToolCalls[1].Name = %q, want %q", resp.ToolCalls[1].Name, "write_file") + } + if resp.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } +} + +func TestParseJSONLEvents_MultipleMessages(t *testing.T) { + p := &CodexCliProvider{} + events := `{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"First part."}} +{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"ls","status":"completed"}} +{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Second part."}} +{"type":"turn.completed"}` + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("parseJSONLEvents() error: %v", err) + } + if resp.Content != "First part.\nSecond part." { + t.Errorf("Content = %q, want %q", resp.Content, "First part.\nSecond part.") + } +} + +func TestParseJSONLEvents_ErrorEvent(t *testing.T) { + p := &CodexCliProvider{} + events := `{"type":"thread.started","thread_id":"abc"} +{"type":"turn.started"} +{"type":"error","message":"token expired"} +{"type":"turn.failed","error":{"message":"token expired"}}` + + _, err := p.parseJSONLEvents(events) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "token expired") { + t.Errorf("error = %q, want to contain 'token expired'", err.Error()) + } +} + +func TestParseJSONLEvents_TurnFailed(t *testing.T) { + p := &CodexCliProvider{} + events := `{"type":"turn.started"} +{"type":"turn.failed","error":{"message":"rate limit exceeded"}}` + + _, err := p.parseJSONLEvents(events) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "rate limit exceeded") { + t.Errorf("error = %q, want to contain 'rate limit exceeded'", err.Error()) + } +} + +func TestParseJSONLEvents_ErrorWithContent(t *testing.T) { + p := &CodexCliProvider{} + // If there's an error but also content, return the content (partial success) + events := `{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Partial result."}} +{"type":"error","message":"connection reset"} +{"type":"turn.failed","error":{"message":"connection reset"}}` + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("should not error when content exists: %v", err) + } + if resp.Content != "Partial result." { + t.Errorf("Content = %q, want %q", resp.Content, "Partial result.") + } +} + +func TestParseJSONLEvents_EmptyOutput(t *testing.T) { + p := &CodexCliProvider{} + resp, err := p.parseJSONLEvents("") + if err != nil { + t.Fatalf("empty output should not error: %v", err) + } + if resp.Content != "" { + t.Errorf("Content = %q, want empty", resp.Content) + } +} + +func TestParseJSONLEvents_MalformedLines(t *testing.T) { + p := &CodexCliProvider{} + events := `not json at all +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Good line."}} +another bad line +{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}` + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("should skip malformed lines: %v", err) + } + if resp.Content != "Good line." { + t.Errorf("Content = %q, want %q", resp.Content, "Good line.") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 15 { + t.Errorf("Usage.TotalTokens = %v, want 15", resp.Usage) + } +} + +func TestParseJSONLEvents_CommandExecution(t *testing.T) { + p := &CodexCliProvider{} + events := `{"type":"turn.started"} +{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}} +{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"completed","exit_code":0,"output":"file1.go\nfile2.go"}} +{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Found 2 files."}} +{"type":"turn.completed"}` + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("parseJSONLEvents() error: %v", err) + } + // command_execution items should be skipped; only agent_message text is returned + if resp.Content != "Found 2 files." { + t.Errorf("Content = %q, want %q", resp.Content, "Found 2 files.") + } +} + +func TestParseJSONLEvents_NoUsage(t *testing.T) { + p := &CodexCliProvider{} + events := `{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"No usage info."}} +{"type":"turn.completed"}` + + resp, err := p.parseJSONLEvents(events) + if err != nil { + t.Fatalf("parseJSONLEvents() error: %v", err) + } + if resp.Usage != nil { + t.Errorf("Usage should be nil when turn.completed has no usage, got %+v", resp.Usage) + } +} + +// --- Prompt Building Tests --- + +func TestBuildPrompt_SystemAsInstructions(t *testing.T) { + p := &CodexCliProvider{} + messages := []Message{ + {Role: "system", Content: "You are helpful."}, + {Role: "user", Content: "Hi there"}, + } + + prompt := p.buildPrompt(messages, nil) + + if !strings.Contains(prompt, "## System Instructions") { + t.Error("prompt should contain '## System Instructions'") + } + if !strings.Contains(prompt, "You are helpful.") { + t.Error("prompt should contain system content") + } + if !strings.Contains(prompt, "## Task") { + t.Error("prompt should contain '## Task'") + } + if !strings.Contains(prompt, "Hi there") { + t.Error("prompt should contain user message") + } +} + +func TestBuildPrompt_NoSystem(t *testing.T) { + p := &CodexCliProvider{} + messages := []Message{ + {Role: "user", Content: "Just a question"}, + } + + prompt := p.buildPrompt(messages, nil) + + if strings.Contains(prompt, "## System Instructions") { + t.Error("prompt should not contain system instructions header") + } + if prompt != "Just a question" { + t.Errorf("prompt = %q, want %q", prompt, "Just a question") + } +} + +func TestBuildPrompt_WithTools(t *testing.T) { + p := &CodexCliProvider{} + messages := []Message{ + {Role: "user", Content: "Get weather"}, + } + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get current weather", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "city": map[string]interface{}{"type": "string"}, + }, + }, + }, + }, + } + + prompt := p.buildPrompt(messages, tools) + + if !strings.Contains(prompt, "## Available Tools") { + t.Error("prompt should contain tools section") + } + if !strings.Contains(prompt, "get_weather") { + t.Error("prompt should contain tool name") + } + if !strings.Contains(prompt, "Get current weather") { + t.Error("prompt should contain tool description") + } +} + +func TestBuildPrompt_MultipleMessages(t *testing.T) { + p := &CodexCliProvider{} + messages := []Message{ + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi! How can I help?"}, + {Role: "user", Content: "Tell me about Go"}, + } + + prompt := p.buildPrompt(messages, nil) + + if !strings.Contains(prompt, "Hello") { + t.Error("prompt should contain first user message") + } + if !strings.Contains(prompt, "Assistant: Hi! How can I help?") { + t.Error("prompt should contain assistant message with prefix") + } + if !strings.Contains(prompt, "Tell me about Go") { + t.Error("prompt should contain second user message") + } +} + +func TestBuildPrompt_ToolResults(t *testing.T) { + p := &CodexCliProvider{} + messages := []Message{ + {Role: "user", Content: "Weather?"}, + {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, + } + + prompt := p.buildPrompt(messages, nil) + + if !strings.Contains(prompt, "[Tool Result for call_1]") { + t.Error("prompt should contain tool result") + } + if !strings.Contains(prompt, `{"temp": 72}`) { + t.Error("prompt should contain tool result content") + } +} + +func TestBuildPrompt_SystemAndTools(t *testing.T) { + p := &CodexCliProvider{} + messages := []Message{ + {Role: "system", Content: "Be concise."}, + {Role: "user", Content: "Do something"}, + } + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "my_tool", + Description: "A tool", + }, + }, + } + + prompt := p.buildPrompt(messages, tools) + + // System instructions should come first + sysIdx := strings.Index(prompt, "## System Instructions") + toolIdx := strings.Index(prompt, "## Available Tools") + taskIdx := strings.Index(prompt, "## Task") + + if sysIdx == -1 || toolIdx == -1 || taskIdx == -1 { + t.Fatal("prompt should contain all sections") + } + if sysIdx >= taskIdx { + t.Error("system instructions should come before task") + } + if taskIdx >= toolIdx { + t.Error("task section should come before tools in the output") + } +} + +// --- CLI Argument Tests --- + +func TestCodexCliProvider_GetDefaultModel(t *testing.T) { + p := NewCodexCliProvider("") + if got := p.GetDefaultModel(); got != "codex-cli" { + t.Errorf("GetDefaultModel() = %q, want %q", got, "codex-cli") + } +} + +// --- Mock CLI Integration Test --- + +func createMockCodexCLI(t *testing.T, events []string) string { + t.Helper() + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "codex") + + var sb strings.Builder + sb.WriteString("#!/bin/bash\n") + for _, event := range events { + sb.WriteString(fmt.Sprintf("echo '%s'\n", event)) + } + + if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil { + t.Fatal(err) + } + return scriptPath +} + +func TestCodexCliProvider_MockCLI_Success(t *testing.T) { + scriptPath := createMockCodexCLI(t, []string{ + `{"type":"thread.started","thread_id":"test-123"}`, + `{"type":"turn.started"}`, + `{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Mock response from Codex CLI"}}`, + `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":15}}`, + }) + + p := &CodexCliProvider{ + command: scriptPath, + workspace: "", + } + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := p.Chat(context.Background(), messages, nil, "", nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Mock response from Codex CLI" { + t.Errorf("Content = %q, want %q", resp.Content, "Mock response from Codex CLI") + } + if resp.Usage == nil { + t.Fatal("Usage should not be nil") + } + if resp.Usage.PromptTokens != 60 { + t.Errorf("PromptTokens = %d, want 60", resp.Usage.PromptTokens) + } + if resp.Usage.CompletionTokens != 15 { + t.Errorf("CompletionTokens = %d, want 15", resp.Usage.CompletionTokens) + } +} + +func TestCodexCliProvider_MockCLI_Error(t *testing.T) { + scriptPath := createMockCodexCLI(t, []string{ + `{"type":"thread.started","thread_id":"test-err"}`, + `{"type":"turn.started"}`, + `{"type":"error","message":"auth token expired"}`, + `{"type":"turn.failed","error":{"message":"auth token expired"}}`, + }) + + p := &CodexCliProvider{ + command: scriptPath, + workspace: "", + } + + messages := []Message{{Role: "user", Content: "Hello"}} + _, err := p.Chat(context.Background(), messages, nil, "", nil) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "auth token expired") { + t.Errorf("error = %q, want to contain 'auth token expired'", err.Error()) + } +} + +func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { + // Mock script that captures args to verify model flag is passed + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "codex") + script := `#!/bin/bash +# Write args to a file for verification +echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" +echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' +echo '{"type":"turn.completed"}'` + + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + p := &CodexCliProvider{ + command: scriptPath, + workspace: "/tmp/test-workspace", + } + + messages := []Message{{Role: "user", Content: "test"}} + _, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + // Verify the args + argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt")) + if err != nil { + t.Fatalf("reading args: %v", err) + } + args := string(argsData) + + if !strings.Contains(args, "-m gpt-5.2-codex") { + t.Errorf("args should contain model flag, got: %s", args) + } + if !strings.Contains(args, "-C /tmp/test-workspace") { + t.Errorf("args should contain workspace flag, got: %s", args) + } + if !strings.Contains(args, "--json") { + t.Errorf("args should contain --json, got: %s", args) + } + if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") { + t.Errorf("args should contain bypass flag, got: %s", args) + } +} + +func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { + // Script that sleeps forever + tmpDir := t.TempDir() + scriptPath := filepath.Join(tmpDir, "codex") + script := "#!/bin/bash\nsleep 60" + + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + p := &CodexCliProvider{ + command: scriptPath, + workspace: "", + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + messages := []Message{{Role: "user", Content: "test"}} + _, err := p.Chat(ctx, messages, nil, "", nil) + if err == nil { + t.Fatal("expected error on canceled context") + } +} + +func TestCodexCliProvider_EmptyCommand(t *testing.T) { + p := &CodexCliProvider{command: ""} + + messages := []Message{{Role: "user", Content: "test"}} + _, err := p.Chat(context.Background(), messages, nil, "", nil) + if err == nil { + t.Fatal("expected error for empty command") + } +} + +// --- Integration Test (requires real codex CLI with valid auth) --- + +func TestCodexCliProvider_Integration(t *testing.T) { + if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" { + t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)") + } + + // Verify codex is available + codexPath, err := exec.LookPath("codex") + if err != nil { + t.Skip("codex CLI not found in PATH") + } + + p := &CodexCliProvider{ + command: codexPath, + workspace: "", + } + + messages := []Message{ + {Role: "user", Content: "Respond with just the word 'hello' and nothing else."}, + } + + resp, err := p.Chat(context.Background(), messages, nil, "", nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + lower := strings.ToLower(strings.TrimSpace(resp.Content)) + if !strings.Contains(lower, "hello") { + t.Errorf("Content = %q, expected to contain 'hello'", resp.Content) + } +} diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 3463389a5..e3526cfb5 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -3,6 +3,7 @@ package providers import ( "context" "encoding/json" + "errors" "fmt" "strings" @@ -10,26 +11,36 @@ import ( "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/logger" ) +const codexDefaultModel = "gpt-5.2" +const codexDefaultInstructions = "You are Codex, a coding assistant." + type CodexProvider struct { - client *openai.Client - accountID string - tokenSource func() (string, string, error) + client *openai.Client + accountID string + tokenSource func() (string, string, error) + enableWebSearch bool } +const defaultCodexInstructions = "You are Codex, a coding assistant." + func NewCodexProvider(token, accountID string) *CodexProvider { opts := []option.RequestOption{ option.WithBaseURL("https://chatgpt.com/backend-api/codex"), option.WithAPIKey(token), + option.WithHeader("originator", "codex_cli_rs"), + option.WithHeader("OpenAI-Beta", "responses=experimental"), } if accountID != "" { opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) } client := openai.NewClient(opts...) return &CodexProvider{ - client: &client, - accountID: accountID, + client: &client, + accountID: accountID, + enableWebSearch: true, } } @@ -41,6 +52,15 @@ func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { var opts []option.RequestOption + accountID := p.accountID + resolvedModel, fallbackReason := resolveCodexModel(model) + if fallbackReason != "" { + logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{ + "requested_model": model, + "resolved_model": resolvedModel, + "reason": fallbackReason, + }) + } if p.tokenSource != nil { tok, accID, err := p.tokenSource() if err != nil { @@ -48,25 +68,123 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To } opts = append(opts, option.WithAPIKey(tok)) if accID != "" { - opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accID)) + accountID = accID } } + if accountID != "" { + opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) + } else { + logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{ + "requested_model": model, + "resolved_model": resolvedModel, + }) + } - params := buildCodexParams(messages, tools, model, options) + params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch) - resp, err := p.client.Responses.New(ctx, params, opts...) + stream := p.client.Responses.NewStreaming(ctx, params, opts...) + defer stream.Close() + + var resp *responses.Response + for stream.Next() { + evt := stream.Current() + if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { + evtResp := evt.Response + if evtResp.ID != "" { + copy := evtResp + resp = © + } + } + } + err := stream.Err() if err != nil { + fields := map[string]interface{}{ + "requested_model": model, + "resolved_model": resolvedModel, + "messages_count": len(messages), + "tools_count": len(tools), + "account_id_present": accountID != "", + "error": err.Error(), + } + var apiErr *openai.Error + if errors.As(err, &apiErr) { + fields["status_code"] = apiErr.StatusCode + fields["api_type"] = apiErr.Type + fields["api_code"] = apiErr.Code + fields["api_param"] = apiErr.Param + fields["api_message"] = apiErr.Message + if apiErr.StatusCode == 400 { + fields["hint"] = "verify account id header and model compatibility for codex backend" + } + if apiErr.Response != nil { + fields["request_id"] = apiErr.Response.Header.Get("x-request-id") + } + } + logger.ErrorCF("provider.codex", "Codex API call failed", fields) return nil, fmt.Errorf("codex API call: %w", err) } + if resp == nil { + fields := map[string]interface{}{ + "requested_model": model, + "resolved_model": resolvedModel, + "messages_count": len(messages), + "tools_count": len(tools), + "account_id_present": accountID != "", + } + logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields) + return nil, fmt.Errorf("codex API call: stream ended without completed response") + } return parseCodexResponse(resp), nil } func (p *CodexProvider) GetDefaultModel() string { - return "gpt-4o" + return codexDefaultModel } -func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams { +func resolveCodexModel(model string) (string, string) { + m := strings.ToLower(strings.TrimSpace(model)) + if m == "" { + return codexDefaultModel, "empty model" + } + + if strings.HasPrefix(m, "openai/") { + m = strings.TrimPrefix(m, "openai/") + } else if strings.Contains(m, "/") { + return codexDefaultModel, "non-openai model namespace" + } + + unsupportedPrefixes := []string{ + "glm", + "claude", + "anthropic", + "gemini", + "google", + "moonshot", + "kimi", + "qwen", + "deepseek", + "llama", + "meta-llama", + "mistral", + "grok", + "xai", + "zhipu", + } + for _, prefix := range unsupportedPrefixes { + if strings.HasPrefix(m, prefix) { + return codexDefaultModel, "unsupported model prefix" + } + } + + if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") { + return m, "" + } + + return codexDefaultModel, "unsupported model family" +} + +func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams { var inputItems responses.ResponseInputParam var instructions string @@ -101,12 +219,18 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, }) } for _, tc := range msg.ToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) + name, args, ok := resolveCodexToolCall(tc) + if !ok { + logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{ + "call_id": tc.ID, + }) + continue + } inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCall: &responses.ResponseFunctionToolCallParam{ CallID: tc.ID, - Name: tc.Name, - Arguments: string(argsJSON), + Name: name, + Arguments: args, }, }) } @@ -133,31 +257,61 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, Input: responses.ResponseNewParamsInputUnion{ OfInputItemList: inputItems, }, - Store: openai.Opt(false), + Instructions: openai.Opt(instructions), + Store: openai.Opt(false), } if instructions != "" { params.Instructions = openai.Opt(instructions) + } else { + // ChatGPT Codex backend requires instructions to be present. + params.Instructions = openai.Opt(defaultCodexInstructions) } - if maxTokens, ok := options["max_tokens"].(int); ok { - params.MaxOutputTokens = openai.Opt(int64(maxTokens)) - } - - if temp, ok := options["temperature"].(float64); ok { - params.Temperature = openai.Opt(temp) - } - - if len(tools) > 0 { - params.Tools = translateToolsForCodex(tools) + if len(tools) > 0 || enableWebSearch { + params.Tools = translateToolsForCodex(tools, enableWebSearch) } return params } -func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { - result := make([]responses.ToolUnionParam, 0, len(tools)) +func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) { + name = tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + if name == "" { + return "", "", false + } + + if len(tc.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true + } + + if tc.Function != nil && tc.Function.Arguments != "" { + return name, tc.Function.Arguments, true + } + + return name, "{}", true +} + +func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { + capHint := len(tools) + if enableWebSearch { + capHint++ + } + result := make([]responses.ToolUnionParam, 0, capHint) for _, t := range tools { + if t.Type != "function" { + continue + } + if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { + continue + } ft := responses.FunctionToolParam{ Name: t.Function.Name, Parameters: t.Function.Parameters, @@ -168,6 +322,9 @@ func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { } result = append(result, responses.ToolUnionParam{OfFunction: &ft}) } + if enableWebSearch { + result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch)) + } return result } @@ -237,6 +394,9 @@ func createCodexTokenSource() func() (string, string, error) { if err != nil { return "", "", fmt.Errorf("refreshing token: %w", err) } + if refreshed.AccountID == "" { + refreshed.AccountID = cred.AccountID + } if err := auth.SetCredential("openai", refreshed); err != nil { return "", "", fmt.Errorf("saving refreshed token: %w", err) } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 605183d5e..92e276165 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -2,6 +2,7 @@ package providers import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -16,11 +17,21 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { {Role: "user", Content: "Hello"}, } params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{ - "max_tokens": 2048, - }) + "max_tokens": 2048, + "temperature": 0.7, + }, true) if params.Model != "gpt-4o" { t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") } + if !params.Instructions.Valid() { + t.Fatal("Instructions should be set") + } + if params.Instructions.Or("") != defaultCodexInstructions { + t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions) + } + if params.MaxOutputTokens.Valid() { + t.Fatalf("MaxOutputTokens should not be set for Codex backend") + } } func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { @@ -28,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true) if !params.Instructions.Valid() { t.Fatal("Instructions should be set") } @@ -48,7 +59,7 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -57,6 +68,45 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { } } +func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "Read a file"}, + { + Role: "assistant", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }, + }, + }, + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + } + + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) + if params.Input.OfInputItemList == nil { + t.Fatal("Input.OfInputItemList should not be nil") + } + if len(params.Input.OfInputItemList) != 3 { + t.Fatalf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) + } + + fc := params.Input.OfInputItemList[1].OfFunctionCall + if fc == nil { + t.Fatal("assistant tool call should be converted to function_call input item") + } + if fc.Name != "read_file" { + t.Errorf("Function call name = %q, want %q", fc.Name, "read_file") + } + if fc.Arguments != `{"path":"README.md"}` { + t.Errorf("Function call arguments = %q, want %q", fc.Arguments, `{"path":"README.md"}`) + } +} + func TestBuildCodexParams_WithTools(t *testing.T) { tools := []ToolDefinition{ { @@ -73,7 +123,7 @@ func TestBuildCodexParams_WithTools(t *testing.T) { }, }, } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } @@ -86,12 +136,61 @@ func TestBuildCodexParams_WithTools(t *testing.T) { } func TestBuildCodexParams_StoreIsFalse(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false) if !params.Store.Valid() || params.Store.Or(true) != false { t.Error("Store should be explicitly set to false") } } +func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) { + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 1 { + t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) + } + if params.Tools[0].OfWebSearch == nil { + t.Fatal("Tool should include built-in web_search") + } + if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch { + t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch) + } +} + +func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "web_search", + Description: "local web search", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "read_file", + Description: "read file", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + } + + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 2 { + t.Fatalf("len(Tools) = %d, want 2", len(params.Tools)) + } + if params.Tools[0].OfFunction == nil || params.Tools[0].OfFunction.Name != "read_file" { + t.Fatalf("first tool should be function read_file, got %#v", params.Tools[0]) + } + if params.Tools[1].OfWebSearch == nil { + t.Fatalf("second tool should be built-in web_search, got %#v", params.Tools[1]) + } +} + func TestParseCodexResponse_TextOutput(t *testing.T) { respJSON := `{ "id": "resp_test", @@ -197,6 +296,30 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { return } + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if reqBody["stream"] != true { + http.Error(w, "stream must be true", http.StatusBadRequest) + return + } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } + toolsAny, ok := reqBody["tools"].([]interface{}) + if !ok || len(toolsAny) != 1 { + http.Error(w, "missing default web search tool", http.StatusBadRequest) + return + } + toolObj, ok := toolsAny[0].(map[string]interface{}) + if !ok || toolObj["type"] != "web_search" { + http.Error(w, "expected web_search tool", http.StatusBadRequest) + return + } + resp := map[string]interface{}{ "id": "resp_test", "object": "response", @@ -220,8 +343,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, }, } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + writeCompletedSSE(w, resp) })) defer server.Close() @@ -244,10 +366,247 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if _, ok := reqBody["tools"]; ok { + http.Error(w, "tools should be absent when web search disabled", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]interface{}{ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]interface{}{ + {"type": "output_text", "text": "Hi from Codex!"}, + }, + }, + }, + "usage": map[string]interface{}{ + "input_tokens": 4, + "output_tokens": 3, + "total_tokens": 7, + "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, + "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + }, + } + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.enableWebSearch = false + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hi from Codex!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") + } +} + +func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer refreshed-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Header.Get("Chatgpt-Account-Id") != "acc-123" { + http.Error(w, "missing account id", http.StatusBadRequest) + return + } + + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if _, ok := reqBody["instructions"]; !ok { + http.Error(w, "missing instructions", http.StatusBadRequest) + return + } + if reqBody["instructions"] == "" { + http.Error(w, "instructions must not be empty", http.StatusBadRequest) + return + } + if _, ok := reqBody["temperature"]; ok { + http.Error(w, "temperature is not supported", http.StatusBadRequest) + return + } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } + if reqBody["stream"] != true { + http.Error(w, "stream must be true", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]interface{}{ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]interface{}{ + {"type": "output_text", "text": "Hi from Codex!"}, + }, + }, + }, + "usage": map[string]interface{}{ + "input_tokens": 8, + "output_tokens": 4, + "total_tokens": 12, + "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, + "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + }, + } + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("stale-token", "acc-123") + provider.client = createOpenAITestClient(server.URL, "stale-token", "") + provider.tokenSource = func() (string, string, error) { + return "refreshed-token", "", nil + } + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hi from Codex!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") + } +} + +func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if reqBody["model"] != codexDefaultModel { + http.Error(w, "unsupported model", http.StatusBadRequest) + return + } + if reqBody["stream"] != true { + http.Error(w, "stream must be true", http.StatusBadRequest) + return + } + if reqBody["instructions"] != codexDefaultInstructions { + http.Error(w, "missing default instructions", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]interface{}{ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]interface{}{ + {"type": "output_text", "text": "Hi from Codex!"}, + }, + }, + }, + "usage": map[string]interface{}{ + "input_tokens": 8, + "output_tokens": 4, + "total_tokens": 12, + "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, + "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + }, + } + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hi from Codex!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") + } +} + func TestCodexProvider_GetDefaultModel(t *testing.T) { p := NewCodexProvider("test-token", "") - if got := p.GetDefaultModel(); got != "gpt-4o" { - t.Errorf("GetDefaultModel() = %q, want %q", got, "gpt-4o") + if got := p.GetDefaultModel(); got != codexDefaultModel { + t.Errorf("GetDefaultModel() = %q, want %q", got, codexDefaultModel) + } +} + +func TestResolveCodexModel(t *testing.T) { + tests := []struct { + name string + input string + wantModel string + wantFallback bool + }{ + {name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true}, + {name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true}, + {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, + {name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false}, + {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotModel, reason := resolveCodexModel(tt.input) + if gotModel != tt.wantModel { + t.Fatalf("resolveCodexModel(%q) model = %q, want %q", tt.input, gotModel, tt.wantModel) + } + if tt.wantFallback && reason == "" { + t.Fatalf("resolveCodexModel(%q) expected fallback reason", tt.input) + } + if !tt.wantFallback && reason != "" { + t.Fatalf("resolveCodexModel(%q) unexpected fallback reason: %q", tt.input, reason) + } + }) } } @@ -262,3 +621,16 @@ func createOpenAITestClient(baseURL, token, accountID string) *openai.Client { c := openai.NewClient(opts...) return &c } + +func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) { + event := map[string]interface{}{ + "type": "response.completed", + "sequence_number": 1, + "response": response, + } + b, _ := json.Marshal(event) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: response.completed\n") + fmt.Fprintf(w, "data: %s\n\n", string(b)) + fmt.Fprintf(w, "data: [DONE]\n\n") +} diff --git a/pkg/providers/cooldown.go b/pkg/providers/cooldown.go new file mode 100644 index 000000000..b0d8608dc --- /dev/null +++ b/pkg/providers/cooldown.go @@ -0,0 +1,207 @@ +package providers + +import ( + "math" + "sync" + "time" +) + +const ( + defaultFailureWindow = 24 * time.Hour +) + +// CooldownTracker manages per-provider cooldown state for the fallback chain. +// Thread-safe via sync.RWMutex. In-memory only (resets on restart). +type CooldownTracker struct { + mu sync.RWMutex + entries map[string]*cooldownEntry + failureWindow time.Duration + nowFunc func() time.Time // for testing +} + +type cooldownEntry struct { + ErrorCount int + FailureCounts map[FailoverReason]int + CooldownEnd time.Time // standard cooldown expiry + DisabledUntil time.Time // billing-specific disable expiry + DisabledReason FailoverReason // reason for disable (billing) + LastFailure time.Time +} + +// NewCooldownTracker creates a tracker with default 24h failure window. +func NewCooldownTracker() *CooldownTracker { + return &CooldownTracker{ + entries: make(map[string]*cooldownEntry), + failureWindow: defaultFailureWindow, + nowFunc: time.Now, + } +} + +// MarkFailure records a failure for a provider and sets appropriate cooldown. +// Resets error counts if last failure was more than failureWindow ago. +func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) { + ct.mu.Lock() + defer ct.mu.Unlock() + + now := ct.nowFunc() + entry := ct.getOrCreate(provider) + + // 24h failure window reset: if no failure in failureWindow, reset counters. + if !entry.LastFailure.IsZero() && now.Sub(entry.LastFailure) > ct.failureWindow { + entry.ErrorCount = 0 + entry.FailureCounts = make(map[FailoverReason]int) + } + + entry.ErrorCount++ + entry.FailureCounts[reason]++ + entry.LastFailure = now + + if reason == FailoverBilling { + billingCount := entry.FailureCounts[FailoverBilling] + entry.DisabledUntil = now.Add(calculateBillingCooldown(billingCount)) + entry.DisabledReason = FailoverBilling + } else { + entry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount)) + } +} + +// MarkSuccess resets all counters and cooldowns for a provider. +func (ct *CooldownTracker) MarkSuccess(provider string) { + ct.mu.Lock() + defer ct.mu.Unlock() + + entry := ct.entries[provider] + if entry == nil { + return + } + + entry.ErrorCount = 0 + entry.FailureCounts = make(map[FailoverReason]int) + entry.CooldownEnd = time.Time{} + entry.DisabledUntil = time.Time{} + entry.DisabledReason = "" +} + +// IsAvailable returns true if the provider is not in cooldown or disabled. +func (ct *CooldownTracker) IsAvailable(provider string) bool { + ct.mu.RLock() + defer ct.mu.RUnlock() + + entry := ct.entries[provider] + if entry == nil { + return true + } + + now := ct.nowFunc() + + // Billing disable takes precedence (longer cooldown). + if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) { + return false + } + + // Standard cooldown. + if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) { + return false + } + + return true +} + +// CooldownRemaining returns how long until the provider becomes available. +// Returns 0 if already available. +func (ct *CooldownTracker) CooldownRemaining(provider string) time.Duration { + ct.mu.RLock() + defer ct.mu.RUnlock() + + entry := ct.entries[provider] + if entry == nil { + return 0 + } + + now := ct.nowFunc() + var remaining time.Duration + + if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) { + d := entry.DisabledUntil.Sub(now) + if d > remaining { + remaining = d + } + } + + if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) { + d := entry.CooldownEnd.Sub(now) + if d > remaining { + remaining = d + } + } + + return remaining +} + +// ErrorCount returns the current error count for a provider. +func (ct *CooldownTracker) ErrorCount(provider string) int { + ct.mu.RLock() + defer ct.mu.RUnlock() + + entry := ct.entries[provider] + if entry == nil { + return 0 + } + return entry.ErrorCount +} + +// FailureCount returns the failure count for a specific reason. +func (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason) int { + ct.mu.RLock() + defer ct.mu.RUnlock() + + entry := ct.entries[provider] + if entry == nil { + return 0 + } + return entry.FailureCounts[reason] +} + +func (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry { + entry := ct.entries[provider] + if entry == nil { + entry = &cooldownEntry{ + FailureCounts: make(map[FailoverReason]int), + } + ct.entries[provider] = entry + } + return entry +} + +// calculateStandardCooldown computes standard exponential backoff. +// Formula from OpenClaw: min(1h, 1min * 5^min(n-1, 3)) +// +// 1 error → 1 min +// 2 errors → 5 min +// 3 errors → 25 min +// 4+ errors → 1 hour (cap) +func calculateStandardCooldown(errorCount int) time.Duration { + n := max(1, errorCount) + exp := min(n-1, 3) + ms := 60_000 * int(math.Pow(5, float64(exp))) + ms = min(3_600_000, ms) // cap at 1 hour + return time.Duration(ms) * time.Millisecond +} + +// calculateBillingCooldown computes billing-specific exponential backoff. +// Formula from OpenClaw: min(24h, 5h * 2^min(n-1, 10)) +// +// 1 error → 5 hours +// 2 errors → 10 hours +// 3 errors → 20 hours +// 4+ errors → 24 hours (cap) +func calculateBillingCooldown(billingErrorCount int) time.Duration { + const baseMs = 5 * 60 * 60 * 1000 // 5 hours + const maxMs = 24 * 60 * 60 * 1000 // 24 hours + + n := max(1, billingErrorCount) + exp := min(n-1, 10) + raw := float64(baseMs) * math.Pow(2, float64(exp)) + ms := int(math.Min(float64(maxMs), raw)) + return time.Duration(ms) * time.Millisecond +} diff --git a/pkg/providers/cooldown_test.go b/pkg/providers/cooldown_test.go new file mode 100644 index 000000000..47f43ad5c --- /dev/null +++ b/pkg/providers/cooldown_test.go @@ -0,0 +1,269 @@ +package providers + +import ( + "sync" + "testing" + "time" +) + +func newTestTracker(now time.Time) (*CooldownTracker, *time.Time) { + current := now + ct := NewCooldownTracker() + ct.nowFunc = func() time.Time { return current } + return ct, ¤t +} + +func TestCooldown_InitiallyAvailable(t *testing.T) { + ct := NewCooldownTracker() + if !ct.IsAvailable("openai") { + t.Error("new provider should be available") + } + if ct.ErrorCount("openai") != 0 { + t.Error("new provider should have 0 errors") + } +} + +func TestCooldown_StandardEscalation(t *testing.T) { + now := time.Now() + ct, current := newTestTracker(now) + + // 1st error → 1 min cooldown + ct.MarkFailure("openai", FailoverRateLimit) + if ct.IsAvailable("openai") { + t.Error("should be in cooldown after 1st error") + } + + // Advance 61 seconds → available + *current = now.Add(61 * time.Second) + if !ct.IsAvailable("openai") { + t.Error("should be available after 1 min cooldown") + } + + // 2nd error → 5 min cooldown + ct.MarkFailure("openai", FailoverRateLimit) + *current = now.Add(61*time.Second + 4*time.Minute) + if ct.IsAvailable("openai") { + t.Error("should be in cooldown (5 min) after 2nd error") + } + *current = now.Add(61*time.Second + 6*time.Minute) + if !ct.IsAvailable("openai") { + t.Error("should be available after 5 min cooldown") + } +} + +func TestCooldown_StandardCap(t *testing.T) { + // Verify formula: 1m, 5m, 25m, 1h, 1h, 1h... + expected := []time.Duration{ + 1 * time.Minute, + 5 * time.Minute, + 25 * time.Minute, + 1 * time.Hour, + 1 * time.Hour, + } + + for i, want := range expected { + got := calculateStandardCooldown(i + 1) + if got != want { + t.Errorf("calculateStandardCooldown(%d) = %v, want %v", i+1, got, want) + } + } +} + +func TestCooldown_BillingEscalation(t *testing.T) { + now := time.Now() + ct, current := newTestTracker(now) + + // 1st billing error → 5h cooldown + ct.MarkFailure("openai", FailoverBilling) + if ct.IsAvailable("openai") { + t.Error("should be disabled after billing error") + } + + // Advance 4h → still disabled + *current = now.Add(4 * time.Hour) + if ct.IsAvailable("openai") { + t.Error("should still be disabled (5h cooldown)") + } + + // Advance 5h + 1s → available + *current = now.Add(5*time.Hour + 1*time.Second) + if !ct.IsAvailable("openai") { + t.Error("should be available after 5h billing cooldown") + } +} + +func TestCooldown_BillingCap(t *testing.T) { + expected := []time.Duration{ + 5 * time.Hour, + 10 * time.Hour, + 20 * time.Hour, + 24 * time.Hour, + 24 * time.Hour, + } + + for i, want := range expected { + got := calculateBillingCooldown(i + 1) + if got != want { + t.Errorf("calculateBillingCooldown(%d) = %v, want %v", i+1, got, want) + } + } +} + +func TestCooldown_SuccessReset(t *testing.T) { + ct := NewCooldownTracker() + + ct.MarkFailure("openai", FailoverRateLimit) + ct.MarkFailure("openai", FailoverBilling) + if ct.ErrorCount("openai") != 2 { + t.Errorf("error count = %d, want 2", ct.ErrorCount("openai")) + } + + ct.MarkSuccess("openai") + if ct.ErrorCount("openai") != 0 { + t.Errorf("error count after success = %d, want 0", ct.ErrorCount("openai")) + } + if !ct.IsAvailable("openai") { + t.Error("should be available after success") + } + if ct.FailureCount("openai", FailoverRateLimit) != 0 { + t.Error("failure counts should be reset after success") + } + if ct.FailureCount("openai", FailoverBilling) != 0 { + t.Error("billing failure count should be reset after success") + } +} + +func TestCooldown_FailureWindowReset(t *testing.T) { + now := time.Now() + ct, current := newTestTracker(now) + + // 4 errors → 1h cooldown + for i := 0; i < 4; i++ { + ct.MarkFailure("openai", FailoverRateLimit) + *current = current.Add(2 * time.Second) // small advance between errors + } + if ct.ErrorCount("openai") != 4 { + t.Errorf("error count = %d, want 4", ct.ErrorCount("openai")) + } + + // Advance 25 hours (past 24h failure window) + *current = now.Add(25 * time.Hour) + + // Next error should reset counters first, then increment to 1 + ct.MarkFailure("openai", FailoverRateLimit) + if ct.ErrorCount("openai") != 1 { + t.Errorf("error count after window reset = %d, want 1 (reset + 1)", ct.ErrorCount("openai")) + } +} + +func TestCooldown_PerReasonTracking(t *testing.T) { + ct := NewCooldownTracker() + + ct.MarkFailure("openai", FailoverRateLimit) + ct.MarkFailure("openai", FailoverRateLimit) + ct.MarkFailure("openai", FailoverBilling) + ct.MarkFailure("openai", FailoverAuth) + + if ct.FailureCount("openai", FailoverRateLimit) != 2 { + t.Errorf("rate_limit count = %d, want 2", ct.FailureCount("openai", FailoverRateLimit)) + } + if ct.FailureCount("openai", FailoverBilling) != 1 { + t.Errorf("billing count = %d, want 1", ct.FailureCount("openai", FailoverBilling)) + } + if ct.FailureCount("openai", FailoverAuth) != 1 { + t.Errorf("auth count = %d, want 1", ct.FailureCount("openai", FailoverAuth)) + } + if ct.ErrorCount("openai") != 4 { + t.Errorf("total error count = %d, want 4", ct.ErrorCount("openai")) + } +} + +func TestCooldown_BillingTakesPrecedence(t *testing.T) { + now := time.Now() + ct, current := newTestTracker(now) + + // Standard cooldown (1 min) + billing disable (5h) + ct.MarkFailure("openai", FailoverRateLimit) // 1 min cooldown + ct.MarkFailure("openai", FailoverBilling) // 5h disable + + // After 2 min: standard cooldown expired but billing still active + *current = now.Add(2 * time.Minute) + if ct.IsAvailable("openai") { + t.Error("billing disable should take precedence over standard cooldown") + } + + // After 5h + 1s: both expired + *current = now.Add(5*time.Hour + 1*time.Second) + if !ct.IsAvailable("openai") { + t.Error("should be available after all cooldowns expire") + } +} + +func TestCooldown_CooldownRemaining(t *testing.T) { + now := time.Now() + ct, current := newTestTracker(now) + + // No failures → 0 remaining + if ct.CooldownRemaining("openai") != 0 { + t.Error("expected 0 remaining for new provider") + } + + ct.MarkFailure("openai", FailoverRateLimit) + + *current = now.Add(30 * time.Second) + remaining := ct.CooldownRemaining("openai") + if remaining <= 0 || remaining > 1*time.Minute { + t.Errorf("remaining = %v, expected ~30s", remaining) + } +} + +func TestCooldown_SuccessOnUnknownProvider(t *testing.T) { + ct := NewCooldownTracker() + // Should not panic + ct.MarkSuccess("nonexistent") + if !ct.IsAvailable("nonexistent") { + t.Error("nonexistent provider should be available") + } +} + +func TestCooldown_ConcurrentAccess(t *testing.T) { + ct := NewCooldownTracker() + var wg sync.WaitGroup + + for i := 0; i < 100; i++ { + wg.Add(3) + go func() { + defer wg.Done() + ct.MarkFailure("openai", FailoverRateLimit) + }() + go func() { + defer wg.Done() + ct.IsAvailable("openai") + }() + go func() { + defer wg.Done() + ct.MarkSuccess("openai") + }() + } + + wg.Wait() + // If we got here without panic, concurrent access is safe +} + +func TestCooldown_MultipleProviders(t *testing.T) { + ct := NewCooldownTracker() + + ct.MarkFailure("openai", FailoverRateLimit) + ct.MarkFailure("anthropic", FailoverBilling) + + if ct.IsAvailable("openai") { + t.Error("openai should be in cooldown") + } + if ct.IsAvailable("anthropic") { + t.Error("anthropic should be in cooldown") + } + // groq was never touched + if !ct.IsAvailable("groq") { + t.Error("groq should be available") + } +} diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go new file mode 100644 index 000000000..a0f003006 --- /dev/null +++ b/pkg/providers/error_classifier.go @@ -0,0 +1,253 @@ +package providers + +import ( + "context" + "regexp" + "strings" +) + +// errorPattern defines a single pattern (string or regex) for error classification. +type errorPattern struct { + substring string + regex *regexp.Regexp +} + +func substr(s string) errorPattern { return errorPattern{substring: s} } +func rxp(r string) errorPattern { return errorPattern{regex: regexp.MustCompile("(?i)" + r)} } + +// Error patterns organized by FailoverReason, matching OpenClaw production (~40 patterns). +var ( + rateLimitPatterns = []errorPattern{ + rxp(`rate[_ ]limit`), + substr("too many requests"), + substr("429"), + substr("exceeded your current quota"), + rxp(`exceeded.*quota`), + rxp(`resource has been exhausted`), + rxp(`resource.*exhausted`), + substr("resource_exhausted"), + substr("quota exceeded"), + substr("usage limit"), + } + + overloadedPatterns = []errorPattern{ + rxp(`overloaded_error`), + rxp(`"type"\s*:\s*"overloaded_error"`), + substr("overloaded"), + } + + timeoutPatterns = []errorPattern{ + substr("timeout"), + substr("timed out"), + substr("deadline exceeded"), + substr("context deadline exceeded"), + } + + billingPatterns = []errorPattern{ + rxp(`\b402\b`), + substr("payment required"), + substr("insufficient credits"), + substr("credit balance"), + substr("plans & billing"), + substr("insufficient balance"), + } + + authPatterns = []errorPattern{ + rxp(`invalid[_ ]?api[_ ]?key`), + substr("incorrect api key"), + substr("invalid token"), + substr("authentication"), + substr("re-authenticate"), + substr("oauth token refresh failed"), + substr("unauthorized"), + substr("forbidden"), + substr("access denied"), + substr("expired"), + substr("token has expired"), + rxp(`\b401\b`), + rxp(`\b403\b`), + substr("no credentials found"), + substr("no api key found"), + } + + formatPatterns = []errorPattern{ + substr("string should match pattern"), + substr("tool_use.id"), + substr("tool_use_id"), + substr("messages.1.content.1.tool_use.id"), + substr("invalid request format"), + } + + imageDimensionPatterns = []errorPattern{ + rxp(`image dimensions exceed max`), + } + + imageSizePatterns = []errorPattern{ + rxp(`image exceeds.*mb`), + } + + // Transient HTTP status codes that map to timeout (server-side failures). + transientStatusCodes = map[int]bool{ + 500: true, 502: true, 503: true, + 521: true, 522: true, 523: true, 524: true, + 529: true, + } +) + +// ClassifyError classifies an error into a FailoverError with reason. +// Returns nil if the error is not classifiable (unknown errors should not trigger fallback). +func ClassifyError(err error, provider, model string) *FailoverError { + if err == nil { + return nil + } + + // Context cancellation: user abort, never fallback. + if err == context.Canceled { + return nil + } + + // Context deadline exceeded: treat as timeout, always fallback. + if err == context.DeadlineExceeded { + return &FailoverError{ + Reason: FailoverTimeout, + Provider: provider, + Model: model, + Wrapped: err, + } + } + + msg := strings.ToLower(err.Error()) + + // Image dimension/size errors: non-retriable, non-fallback. + if IsImageDimensionError(msg) || IsImageSizeError(msg) { + return &FailoverError{ + Reason: FailoverFormat, + Provider: provider, + Model: model, + Wrapped: err, + } + } + + // Try HTTP status code extraction first. + if status := extractHTTPStatus(msg); status > 0 { + if reason := classifyByStatus(status); reason != "" { + return &FailoverError{ + Reason: reason, + Provider: provider, + Model: model, + Status: status, + Wrapped: err, + } + } + } + + // Message pattern matching (priority order from OpenClaw). + if reason := classifyByMessage(msg); reason != "" { + return &FailoverError{ + Reason: reason, + Provider: provider, + Model: model, + Wrapped: err, + } + } + + return nil +} + +// classifyByStatus maps HTTP status codes to FailoverReason. +func classifyByStatus(status int) FailoverReason { + switch { + case status == 401 || status == 403: + return FailoverAuth + case status == 402: + return FailoverBilling + case status == 408: + return FailoverTimeout + case status == 429: + return FailoverRateLimit + case status == 400: + return FailoverFormat + case transientStatusCodes[status]: + return FailoverTimeout + } + return "" +} + +// classifyByMessage matches error messages against patterns. +// Priority order matters (from OpenClaw classifyFailoverReason). +func classifyByMessage(msg string) FailoverReason { + if matchesAny(msg, rateLimitPatterns) { + return FailoverRateLimit + } + if matchesAny(msg, overloadedPatterns) { + return FailoverRateLimit // Overloaded treated as rate_limit + } + if matchesAny(msg, billingPatterns) { + return FailoverBilling + } + if matchesAny(msg, timeoutPatterns) { + return FailoverTimeout + } + if matchesAny(msg, authPatterns) { + return FailoverAuth + } + if matchesAny(msg, formatPatterns) { + return FailoverFormat + } + return "" +} + +// extractHTTPStatus extracts an HTTP status code from an error message. +// Looks for patterns like "status: 429", "status 429", "HTTP 429", or standalone "429". +func extractHTTPStatus(msg string) int { + // Common patterns in Go HTTP error messages + patterns := []*regexp.Regexp{ + regexp.MustCompile(`status[:\s]+(\d{3})`), + regexp.MustCompile(`HTTP[/\s]+\d*\.?\d*\s+(\d{3})`), + } + + for _, p := range patterns { + if m := p.FindStringSubmatch(msg); len(m) > 1 { + return parseDigits(m[1]) + } + } + + return 0 +} + +// IsImageDimensionError returns true if the message indicates an image dimension error. +func IsImageDimensionError(msg string) bool { + return matchesAny(msg, imageDimensionPatterns) +} + +// IsImageSizeError returns true if the message indicates an image file size error. +func IsImageSizeError(msg string) bool { + return matchesAny(msg, imageSizePatterns) +} + +// matchesAny checks if msg matches any of the patterns. +func matchesAny(msg string, patterns []errorPattern) bool { + for _, p := range patterns { + if p.regex != nil { + if p.regex.MatchString(msg) { + return true + } + } else if p.substring != "" { + if strings.Contains(msg, p.substring) { + return true + } + } + } + return false +} + +// parseDigits converts a string of digits to an int. +func parseDigits(s string) int { + n := 0 + for _, c := range s { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + return n +} diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go new file mode 100644 index 000000000..865aea57a --- /dev/null +++ b/pkg/providers/error_classifier_test.go @@ -0,0 +1,337 @@ +package providers + +import ( + "context" + "errors" + "fmt" + "testing" +) + +func TestClassifyError_Nil(t *testing.T) { + result := ClassifyError(nil, "openai", "gpt-4") + if result != nil { + t.Errorf("expected nil for nil error, got %+v", result) + } +} + +func TestClassifyError_ContextCanceled(t *testing.T) { + result := ClassifyError(context.Canceled, "openai", "gpt-4") + if result != nil { + t.Errorf("expected nil for context.Canceled (user abort), got %+v", result) + } +} + +func TestClassifyError_ContextDeadlineExceeded(t *testing.T) { + result := ClassifyError(context.DeadlineExceeded, "openai", "gpt-4") + if result == nil { + t.Fatal("expected non-nil for deadline exceeded") + } + if result.Reason != FailoverTimeout { + t.Errorf("reason = %q, want timeout", result.Reason) + } +} + +func TestClassifyError_StatusCodes(t *testing.T) { + tests := []struct { + status int + reason FailoverReason + }{ + {401, FailoverAuth}, + {403, FailoverAuth}, + {402, FailoverBilling}, + {408, FailoverTimeout}, + {429, FailoverRateLimit}, + {400, FailoverFormat}, + {500, FailoverTimeout}, + {502, FailoverTimeout}, + {503, FailoverTimeout}, + {521, FailoverTimeout}, + {522, FailoverTimeout}, + {523, FailoverTimeout}, + {524, FailoverTimeout}, + {529, FailoverTimeout}, + } + + for _, tt := range tests { + err := fmt.Errorf("API error: status: %d something went wrong", tt.status) + result := ClassifyError(err, "test", "model") + if result == nil { + t.Errorf("status %d: expected non-nil", tt.status) + continue + } + if result.Reason != tt.reason { + t.Errorf("status %d: reason = %q, want %q", tt.status, result.Reason, tt.reason) + } + } +} + +func TestClassifyError_RateLimitPatterns(t *testing.T) { + patterns := []string{ + "rate limit exceeded", + "rate_limit reached", + "too many requests", + "exceeded your current quota", + "resource has been exhausted", + "resource_exhausted", + "quota exceeded", + "usage limit reached", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverRateLimit { + t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason) + } + } +} + +func TestClassifyError_OverloadedPatterns(t *testing.T) { + patterns := []string{ + "overloaded_error", + `{"type": "overloaded_error"}`, + "server is overloaded", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "anthropic", "claude") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + // Overloaded is treated as rate_limit + if result.Reason != FailoverRateLimit { + t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason) + } + } +} + +func TestClassifyError_BillingPatterns(t *testing.T) { + patterns := []string{ + "payment required", + "insufficient credits", + "credit balance too low", + "plans & billing page", + "insufficient balance", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverBilling { + t.Errorf("pattern %q: reason = %q, want billing", msg, result.Reason) + } + } +} + +func TestClassifyError_TimeoutPatterns(t *testing.T) { + patterns := []string{ + "request timeout", + "connection timed out", + "deadline exceeded", + "context deadline exceeded", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverTimeout { + t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason) + } + } +} + +func TestClassifyError_AuthPatterns(t *testing.T) { + patterns := []string{ + "invalid api key", + "invalid_api_key", + "incorrect api key", + "invalid token", + "authentication failed", + "re-authenticate", + "oauth token refresh failed", + "unauthorized access", + "forbidden", + "access denied", + "expired", + "token has expired", + "no credentials found", + "no api key found", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverAuth { + t.Errorf("pattern %q: reason = %q, want auth", msg, result.Reason) + } + } +} + +func TestClassifyError_FormatPatterns(t *testing.T) { + patterns := []string{ + "string should match pattern", + "tool_use.id is required", + "invalid tool_use_id", + "messages.1.content.1.tool_use.id must be valid", + "invalid request format", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "anthropic", "claude") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverFormat { + t.Errorf("pattern %q: reason = %q, want format", msg, result.Reason) + } + } +} + +func TestClassifyError_ImageDimensionError(t *testing.T) { + err := errors.New("image dimensions exceed max allowed 2048x2048") + result := ClassifyError(err, "openai", "gpt-4o") + if result == nil { + t.Fatal("expected non-nil for image dimension error") + } + if result.Reason != FailoverFormat { + t.Errorf("reason = %q, want format", result.Reason) + } + if result.IsRetriable() { + t.Error("image dimension error should not be retriable") + } +} + +func TestClassifyError_ImageSizeError(t *testing.T) { + err := errors.New("image exceeds 20 mb limit") + result := ClassifyError(err, "openai", "gpt-4o") + if result == nil { + t.Fatal("expected non-nil for image size error") + } + if result.Reason != FailoverFormat { + t.Errorf("reason = %q, want format", result.Reason) + } +} + +func TestClassifyError_UnknownError(t *testing.T) { + err := errors.New("some completely random error") + result := ClassifyError(err, "openai", "gpt-4") + if result != nil { + t.Errorf("expected nil for unknown error, got %+v", result) + } +} + +func TestClassifyError_ProviderModelPropagation(t *testing.T) { + err := errors.New("rate limit exceeded") + result := ClassifyError(err, "my-provider", "my-model") + if result == nil { + t.Fatal("expected non-nil") + } + if result.Provider != "my-provider" { + t.Errorf("provider = %q, want my-provider", result.Provider) + } + if result.Model != "my-model" { + t.Errorf("model = %q, want my-model", result.Model) + } +} + +func TestFailoverError_IsRetriable(t *testing.T) { + tests := []struct { + reason FailoverReason + retriable bool + }{ + {FailoverAuth, true}, + {FailoverRateLimit, true}, + {FailoverBilling, true}, + {FailoverTimeout, true}, + {FailoverOverloaded, true}, + {FailoverFormat, false}, + {FailoverUnknown, true}, + } + + for _, tt := range tests { + fe := &FailoverError{Reason: tt.reason} + if fe.IsRetriable() != tt.retriable { + t.Errorf("IsRetriable(%q) = %v, want %v", tt.reason, fe.IsRetriable(), tt.retriable) + } + } +} + +func TestFailoverError_ErrorString(t *testing.T) { + fe := &FailoverError{ + Reason: FailoverRateLimit, + Provider: "openai", + Model: "gpt-4", + Status: 429, + Wrapped: errors.New("too many requests"), + } + s := fe.Error() + if s == "" { + t.Error("expected non-empty error string") + } +} + +func TestFailoverError_Unwrap(t *testing.T) { + inner := errors.New("inner error") + fe := &FailoverError{Reason: FailoverTimeout, Wrapped: inner} + if fe.Unwrap() != inner { + t.Error("Unwrap should return wrapped error") + } +} + +func TestExtractHTTPStatus(t *testing.T) { + tests := []struct { + msg string + want int + }{ + {"status: 429 rate limited", 429}, + {"status 401 unauthorized", 401}, + {"HTTP/1.1 502 Bad Gateway", 502}, + {"no status code here", 0}, + {"random number 12345", 0}, + } + + for _, tt := range tests { + got := extractHTTPStatus(tt.msg) + if got != tt.want { + t.Errorf("extractHTTPStatus(%q) = %d, want %d", tt.msg, got, tt.want) + } + } +} + +func TestIsImageDimensionError(t *testing.T) { + if !IsImageDimensionError("image dimensions exceed max 4096x4096") { + t.Error("should match image dimensions exceed max") + } + if IsImageDimensionError("normal error message") { + t.Error("should not match normal error") + } +} + +func TestIsImageSizeError(t *testing.T) { + if !IsImageSizeError("image exceeds 20 mb") { + t.Error("should match image exceeds mb") + } + if IsImageSizeError("normal error message") { + t.Error("should not match normal error") + } +} diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go new file mode 100644 index 000000000..e39cfe32b --- /dev/null +++ b/pkg/providers/factory.go @@ -0,0 +1,360 @@ +package providers + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +const defaultAnthropicAPIBase = "https://api.anthropic.com/v1" + +var getCredential = auth.GetCredential + +type providerType int + +const ( + providerTypeHTTPCompat providerType = iota + providerTypeClaudeAuth + providerTypeCodexAuth + providerTypeCodexCLIToken + providerTypeClaudeCLI + providerTypeCodexCLI + providerTypeGitHubCopilot +) + +type providerSelection struct { + providerType providerType + apiKey string + apiBase string + proxy string + model string + workspace string + connectMode string + enableWebSearch bool +} + +func createClaudeAuthProvider(apiBase string) (LLMProvider, error) { + if apiBase == "" { + apiBase = defaultAnthropicAPIBase + } + cred, err := getCredential("anthropic") + if err != nil { + return nil, fmt.Errorf("loading auth credentials: %w", err) + } + if cred == nil { + return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") + } + return NewClaudeProviderWithTokenSourceAndBaseURL(cred.AccessToken, createClaudeTokenSource(), apiBase), nil +} + +func createCodexAuthProvider(enableWebSearch bool) (LLMProvider, error) { + cred, err := getCredential("openai") + if err != nil { + return nil, fmt.Errorf("loading auth credentials: %w", err) + } + if cred == nil { + return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") + } + p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()) + p.enableWebSearch = enableWebSearch + return p, nil +} + +func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { + model := cfg.Agents.Defaults.Model + providerName := strings.ToLower(cfg.Agents.Defaults.Provider) + lowerModel := strings.ToLower(model) + + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + } + + // First, prefer explicit provider configuration. + if providerName != "" { + switch providerName { + case "groq": + if cfg.Providers.Groq.APIKey != "" { + sel.apiKey = cfg.Providers.Groq.APIKey + sel.apiBase = cfg.Providers.Groq.APIBase + sel.proxy = cfg.Providers.Groq.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.groq.com/openai/v1" + } + } + case "openai", "gpt": + if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { + sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch + if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { + sel.providerType = providerTypeCodexCLIToken + return sel, nil + } + if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { + sel.providerType = providerTypeCodexAuth + return sel, nil + } + sel.apiKey = cfg.Providers.OpenAI.APIKey + sel.apiBase = cfg.Providers.OpenAI.APIBase + sel.proxy = cfg.Providers.OpenAI.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.openai.com/v1" + } + } + case "anthropic", "claude": + if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } + sel.providerType = providerTypeClaudeAuth + return sel, nil + } + sel.apiKey = cfg.Providers.Anthropic.APIKey + sel.apiBase = cfg.Providers.Anthropic.APIBase + sel.proxy = cfg.Providers.Anthropic.Proxy + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } + } + case "openrouter": + if cfg.Providers.OpenRouter.APIKey != "" { + sel.apiKey = cfg.Providers.OpenRouter.APIKey + sel.proxy = cfg.Providers.OpenRouter.Proxy + if cfg.Providers.OpenRouter.APIBase != "" { + sel.apiBase = cfg.Providers.OpenRouter.APIBase + } else { + sel.apiBase = "https://openrouter.ai/api/v1" + } + } + case "zhipu", "glm": + if cfg.Providers.Zhipu.APIKey != "" { + sel.apiKey = cfg.Providers.Zhipu.APIKey + sel.apiBase = cfg.Providers.Zhipu.APIBase + sel.proxy = cfg.Providers.Zhipu.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" + } + } + case "gemini", "google": + if cfg.Providers.Gemini.APIKey != "" { + sel.apiKey = cfg.Providers.Gemini.APIKey + sel.apiBase = cfg.Providers.Gemini.APIBase + sel.proxy = cfg.Providers.Gemini.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" + } + } + case "vllm": + if cfg.Providers.VLLM.APIBase != "" { + sel.apiKey = cfg.Providers.VLLM.APIKey + sel.apiBase = cfg.Providers.VLLM.APIBase + sel.proxy = cfg.Providers.VLLM.Proxy + } + case "shengsuanyun": + if cfg.Providers.ShengSuanYun.APIKey != "" { + sel.apiKey = cfg.Providers.ShengSuanYun.APIKey + sel.apiBase = cfg.Providers.ShengSuanYun.APIBase + sel.proxy = cfg.Providers.ShengSuanYun.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://router.shengsuanyun.com/api/v1" + } + } + case "nvidia": + if cfg.Providers.Nvidia.APIKey != "" { + sel.apiKey = cfg.Providers.Nvidia.APIKey + sel.apiBase = cfg.Providers.Nvidia.APIBase + sel.proxy = cfg.Providers.Nvidia.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://integrate.api.nvidia.com/v1" + } + } + case "claude-cli", "claude-code", "claudecode": + workspace := cfg.WorkspacePath() + if workspace == "" { + workspace = "." + } + sel.providerType = providerTypeClaudeCLI + sel.workspace = workspace + return sel, nil + case "codex-cli", "codex-code": + workspace := cfg.WorkspacePath() + if workspace == "" { + workspace = "." + } + sel.providerType = providerTypeCodexCLI + sel.workspace = workspace + return sel, nil + case "deepseek": + if cfg.Providers.DeepSeek.APIKey != "" { + sel.apiKey = cfg.Providers.DeepSeek.APIKey + sel.apiBase = cfg.Providers.DeepSeek.APIBase + sel.proxy = cfg.Providers.DeepSeek.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.deepseek.com/v1" + } + if model != "deepseek-chat" && model != "deepseek-reasoner" { + sel.model = "deepseek-chat" + } + } + case "github_copilot", "copilot": + sel.providerType = providerTypeGitHubCopilot + if cfg.Providers.GitHubCopilot.APIBase != "" { + sel.apiBase = cfg.Providers.GitHubCopilot.APIBase + } else { + sel.apiBase = "localhost:4321" + } + sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode + return sel, nil + } + } + + // Fallback: infer provider from model and configured keys. + if sel.apiKey == "" && sel.apiBase == "" { + switch { + case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": + sel.apiKey = cfg.Providers.Moonshot.APIKey + sel.apiBase = cfg.Providers.Moonshot.APIBase + sel.proxy = cfg.Providers.Moonshot.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.moonshot.cn/v1" + } + case strings.HasPrefix(model, "openrouter/") || + strings.HasPrefix(model, "anthropic/") || + strings.HasPrefix(model, "openai/") || + strings.HasPrefix(model, "meta-llama/") || + strings.HasPrefix(model, "deepseek/") || + strings.HasPrefix(model, "google/"): + sel.apiKey = cfg.Providers.OpenRouter.APIKey + sel.proxy = cfg.Providers.OpenRouter.Proxy + if cfg.Providers.OpenRouter.APIBase != "" { + sel.apiBase = cfg.Providers.OpenRouter.APIBase + } else { + sel.apiBase = "https://openrouter.ai/api/v1" + } + case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && + (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } + sel.providerType = providerTypeClaudeAuth + return sel, nil + } + sel.apiKey = cfg.Providers.Anthropic.APIKey + sel.apiBase = cfg.Providers.Anthropic.APIBase + sel.proxy = cfg.Providers.Anthropic.Proxy + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } + case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && + (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): + sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch + if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { + sel.providerType = providerTypeCodexCLIToken + return sel, nil + } + if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { + sel.providerType = providerTypeCodexAuth + return sel, nil + } + sel.apiKey = cfg.Providers.OpenAI.APIKey + sel.apiBase = cfg.Providers.OpenAI.APIBase + sel.proxy = cfg.Providers.OpenAI.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.openai.com/v1" + } + case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": + sel.apiKey = cfg.Providers.Gemini.APIKey + sel.apiBase = cfg.Providers.Gemini.APIBase + sel.proxy = cfg.Providers.Gemini.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" + } + case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": + sel.apiKey = cfg.Providers.Zhipu.APIKey + sel.apiBase = cfg.Providers.Zhipu.APIBase + sel.proxy = cfg.Providers.Zhipu.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" + } + case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": + sel.apiKey = cfg.Providers.Groq.APIKey + sel.apiBase = cfg.Providers.Groq.APIBase + sel.proxy = cfg.Providers.Groq.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.groq.com/openai/v1" + } + case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": + sel.apiKey = cfg.Providers.Nvidia.APIKey + sel.apiBase = cfg.Providers.Nvidia.APIBase + sel.proxy = cfg.Providers.Nvidia.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://integrate.api.nvidia.com/v1" + } + case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "": + sel.apiKey = cfg.Providers.Ollama.APIKey + sel.apiBase = cfg.Providers.Ollama.APIBase + sel.proxy = cfg.Providers.Ollama.Proxy + if sel.apiBase == "" { + sel.apiBase = "http://localhost:11434/v1" + } + case cfg.Providers.VLLM.APIBase != "": + sel.apiKey = cfg.Providers.VLLM.APIKey + sel.apiBase = cfg.Providers.VLLM.APIBase + sel.proxy = cfg.Providers.VLLM.Proxy + default: + if cfg.Providers.OpenRouter.APIKey != "" { + sel.apiKey = cfg.Providers.OpenRouter.APIKey + sel.proxy = cfg.Providers.OpenRouter.Proxy + if cfg.Providers.OpenRouter.APIBase != "" { + sel.apiBase = cfg.Providers.OpenRouter.APIBase + } else { + sel.apiBase = "https://openrouter.ai/api/v1" + } + } else { + return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model) + } + } + } + + if sel.providerType == providerTypeHTTPCompat { + if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") { + return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model) + } + if sel.apiBase == "" { + return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model) + } + } + + return sel, nil +} + +func CreateProvider(cfg *config.Config) (LLMProvider, error) { + sel, err := resolveProviderSelection(cfg) + if err != nil { + return nil, err + } + + switch sel.providerType { + case providerTypeClaudeAuth: + return createClaudeAuthProvider(sel.apiBase) + case providerTypeCodexAuth: + return createCodexAuthProvider(sel.enableWebSearch) + case providerTypeCodexCLIToken: + c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()) + c.enableWebSearch = sel.enableWebSearch + return c, nil + case providerTypeClaudeCLI: + return NewClaudeCliProvider(sel.workspace), nil + case providerTypeCodexCLI: + return NewCodexCliProvider(sel.workspace), nil + case providerTypeGitHubCopilot: + return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model) + default: + return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil + } +} diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go new file mode 100644 index 000000000..e31737eb9 --- /dev/null +++ b/pkg/providers/factory_test.go @@ -0,0 +1,299 @@ +package providers + +import ( + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestResolveProviderSelection(t *testing.T) { + tests := []struct { + name string + setup func(*config.Config) + wantType providerType + wantAPIBase string + wantProxy string + wantErrSubstr string + }{ + { + name: "explicit claude-cli provider routes to cli provider type", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "claude-cli" + cfg.Agents.Defaults.Workspace = "/tmp/ws" + }, + wantType: providerTypeClaudeCLI, + }, + { + name: "explicit copilot provider routes to github copilot type", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "copilot" + }, + wantType: providerTypeGitHubCopilot, + wantAPIBase: "localhost:4321", + }, + { + name: "explicit deepseek provider uses deepseek defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "deepseek" + cfg.Agents.Defaults.Model = "deepseek/deepseek-chat" + cfg.Providers.DeepSeek.APIKey = "deepseek-key" + cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.deepseek.com/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "explicit shengsuanyun provider uses defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "shengsuanyun" + cfg.Providers.ShengSuanYun.APIKey = "ssy-key" + cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://router.shengsuanyun.com/api/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "explicit nvidia provider uses defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "nvidia" + cfg.Providers.Nvidia.APIKey = "nvapi-test" + cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://integrate.api.nvidia.com/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "openrouter model uses openrouter defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "openrouter/auto" + cfg.Providers.OpenRouter.APIKey = "sk-or-test" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://openrouter.ai/api/v1", + }, + { + name: "anthropic oauth routes to claude auth provider", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "claude-sonnet-4-5-20250929" + cfg.Providers.Anthropic.AuthMethod = "oauth" + }, + wantType: providerTypeClaudeAuth, + }, + { + name: "openai oauth routes to codex auth provider", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "gpt-4o" + cfg.Providers.OpenAI.AuthMethod = "oauth" + }, + wantType: providerTypeCodexAuth, + }, + { + name: "openai codex-cli auth routes to codex cli token provider", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "gpt-4o" + cfg.Providers.OpenAI.AuthMethod = "codex-cli" + }, + wantType: providerTypeCodexCLIToken, + }, + { + name: "explicit codex-code provider routes to codex cli provider type", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "codex-code" + cfg.Agents.Defaults.Workspace = "/tmp/ws" + }, + wantType: providerTypeCodexCLI, + }, + { + name: "zhipu model uses zhipu base default", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "glm-4.7" + cfg.Providers.Zhipu.APIKey = "zhipu-key" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://open.bigmodel.cn/api/paas/v4", + }, + { + name: "groq model uses groq base default", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "groq/llama-3.3-70b" + cfg.Providers.Groq.APIKey = "gsk-key" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.groq.com/openai/v1", + }, + { + name: "ollama model uses ollama base default", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b" + cfg.Providers.Ollama.APIKey = "ollama-key" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "http://localhost:11434/v1", + }, + { + name: "moonshot model keeps proxy and default base", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5" + cfg.Providers.Moonshot.APIKey = "moonshot-key" + cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.moonshot.cn/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "missing keys returns model config error", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "custom-model" + }, + wantErrSubstr: "no API key configured for model", + }, + { + name: "openrouter prefix without key returns provider key error", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "openrouter/auto" + }, + wantErrSubstr: "no API key configured for provider", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.DefaultConfig() + tt.setup(cfg) + + got, err := resolveProviderSelection(cfg) + if tt.wantErrSubstr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr) + } + if !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr) + } + return + } + + if err != nil { + t.Fatalf("resolveProviderSelection() error = %v", err) + } + if got.providerType != tt.wantType { + t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType) + } + if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase { + t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase) + } + if tt.wantProxy != "" && got.proxy != tt.wantProxy { + t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy) + } + }) + } +} + +func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Model = "openrouter/auto" + cfg.Providers.OpenRouter.APIKey = "sk-or-test" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("provider type = %T, want *HTTPProvider", provider) + } +} + +func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "codex-code" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + if _, ok := provider.(*CodexCliProvider); !ok { + t.Fatalf("provider type = %T, want *CodexCliProvider", provider) + } +} + +func TestCreateProviderReturnsCodexProviderForCodexCliAuthMethod(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "openai" + cfg.Providers.OpenAI.AuthMethod = "codex-cli" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + if _, ok := provider.(*CodexProvider); !ok { + t.Fatalf("provider type = %T, want *CodexProvider", provider) + } +} + +func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) { + originalGetCredential := getCredential + t.Cleanup(func() { getCredential = originalGetCredential }) + + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "anthropic" { + t.Fatalf("provider = %q, want anthropic", provider) + } + return &auth.AuthCredential{ + AccessToken: "anthropic-token", + }, nil + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "anthropic" + cfg.Providers.Anthropic.AuthMethod = "oauth" + cfg.Providers.Anthropic.APIBase = "https://proxy.example.com/v1" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + claudeProvider, ok := provider.(*ClaudeProvider) + if !ok { + t.Fatalf("provider type = %T, want *ClaudeProvider", provider) + } + if got := claudeProvider.delegate.BaseURL(); got != "https://proxy.example.com" { + t.Fatalf("anthropic baseURL = %q, want %q", got, "https://proxy.example.com") + } +} + +func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) { + originalGetCredential := getCredential + t.Cleanup(func() { getCredential = originalGetCredential }) + + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want openai", provider) + } + return &auth.AuthCredential{ + AccessToken: "openai-token", + AccountID: "acct_123", + }, nil + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "openai" + cfg.Providers.OpenAI.AuthMethod = "oauth" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + if _, ok := provider.(*CodexProvider); !ok { + t.Fatalf("provider type = %T, want *CodexProvider", provider) + } +} diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go new file mode 100644 index 000000000..9b07f9153 --- /dev/null +++ b/pkg/providers/fallback.go @@ -0,0 +1,283 @@ +package providers + +import ( + "context" + "fmt" + "strings" + "time" +) + +// FallbackChain orchestrates model fallback across multiple candidates. +type FallbackChain struct { + cooldown *CooldownTracker +} + +// FallbackCandidate represents one model/provider to try. +type FallbackCandidate struct { + Provider string + Model string +} + +// FallbackResult contains the successful response and metadata about all attempts. +type FallbackResult struct { + Response *LLMResponse + Provider string + Model string + Attempts []FallbackAttempt +} + +// FallbackAttempt records one attempt in the fallback chain. +type FallbackAttempt struct { + Provider string + Model string + Error error + Reason FailoverReason + Duration time.Duration + Skipped bool // true if skipped due to cooldown +} + +// NewFallbackChain creates a new fallback chain with the given cooldown tracker. +func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain { + return &FallbackChain{cooldown: cooldown} +} + +// ResolveCandidates parses model config into a deduplicated candidate list. +func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate { + seen := make(map[string]bool) + var candidates []FallbackCandidate + + addCandidate := func(raw string) { + ref := ParseModelRef(raw, defaultProvider) + if ref == nil { + return + } + key := ModelKey(ref.Provider, ref.Model) + if seen[key] { + return + } + seen[key] = true + candidates = append(candidates, FallbackCandidate{ + Provider: ref.Provider, + Model: ref.Model, + }) + } + + // Primary first. + addCandidate(cfg.Primary) + + // Then fallbacks. + for _, fb := range cfg.Fallbacks { + addCandidate(fb) + } + + return candidates +} + +// Execute runs the fallback chain for text/chat requests. +// It tries each candidate in order, respecting cooldowns and error classification. +// +// Behavior: +// - Candidates in cooldown are skipped (logged as skipped attempt). +// - context.Canceled aborts immediately (user abort, no fallback). +// - Non-retriable errors (format) abort immediately. +// - Retriable errors trigger fallback to next candidate. +// - Success marks provider as good (resets cooldown). +// - If all fail, returns aggregate error with all attempts. +func (fc *FallbackChain) Execute( + ctx context.Context, + candidates []FallbackCandidate, + run func(ctx context.Context, provider, model string) (*LLMResponse, error), +) (*FallbackResult, error) { + if len(candidates) == 0 { + return nil, fmt.Errorf("fallback: no candidates configured") + } + + result := &FallbackResult{ + Attempts: make([]FallbackAttempt, 0, len(candidates)), + } + + for i, candidate := range candidates { + // Check context before each attempt. + if ctx.Err() == context.Canceled { + return nil, context.Canceled + } + + // Check cooldown. + if !fc.cooldown.IsAvailable(candidate.Provider) { + remaining := fc.cooldown.CooldownRemaining(candidate.Provider) + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)), + }) + continue + } + + // Execute the run function. + start := time.Now() + resp, err := run(ctx, candidate.Provider, candidate.Model) + elapsed := time.Since(start) + + if err == nil { + // Success. + fc.cooldown.MarkSuccess(candidate.Provider) + result.Response = resp + result.Provider = candidate.Provider + result.Model = candidate.Model + return result, nil + } + + // Context cancellation: abort immediately, no fallback. + if ctx.Err() == context.Canceled { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: err, + Duration: elapsed, + }) + return nil, context.Canceled + } + + // Classify the error. + failErr := ClassifyError(err, candidate.Provider, candidate.Model) + + if failErr == nil { + // Unclassifiable error: do not fallback, return immediately. + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: err, + Duration: elapsed, + }) + return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", + candidate.Provider, candidate.Model, err) + } + + // Non-retriable error: abort immediately. + if !failErr.IsRetriable() { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: failErr, + Reason: failErr.Reason, + Duration: elapsed, + }) + return nil, failErr + } + + // Retriable error: mark failure and continue to next candidate. + fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason) + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: failErr, + Reason: failErr.Reason, + Duration: elapsed, + }) + + // If this was the last candidate, return aggregate error. + if i == len(candidates)-1 { + return nil, &FallbackExhaustedError{Attempts: result.Attempts} + } + } + + // All candidates were skipped (all in cooldown). + return nil, &FallbackExhaustedError{Attempts: result.Attempts} +} + +// ExecuteImage runs the fallback chain for image/vision requests. +// Simpler than Execute: no cooldown checks (image endpoints have different rate limits). +// Image dimension/size errors abort immediately (non-retriable). +func (fc *FallbackChain) ExecuteImage( + ctx context.Context, + candidates []FallbackCandidate, + run func(ctx context.Context, provider, model string) (*LLMResponse, error), +) (*FallbackResult, error) { + if len(candidates) == 0 { + return nil, fmt.Errorf("image fallback: no candidates configured") + } + + result := &FallbackResult{ + Attempts: make([]FallbackAttempt, 0, len(candidates)), + } + + for i, candidate := range candidates { + if ctx.Err() == context.Canceled { + return nil, context.Canceled + } + + start := time.Now() + resp, err := run(ctx, candidate.Provider, candidate.Model) + elapsed := time.Since(start) + + if err == nil { + result.Response = resp + result.Provider = candidate.Provider + result.Model = candidate.Model + return result, nil + } + + if ctx.Err() == context.Canceled { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: err, + Duration: elapsed, + }) + return nil, context.Canceled + } + + // Image dimension/size errors are non-retriable. + errMsg := strings.ToLower(err.Error()) + if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: err, + Reason: FailoverFormat, + Duration: elapsed, + }) + return nil, &FailoverError{ + Reason: FailoverFormat, + Provider: candidate.Provider, + Model: candidate.Model, + Wrapped: err, + } + } + + // Any other error: record and try next. + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: err, + Duration: elapsed, + }) + + if i == len(candidates)-1 { + return nil, &FallbackExhaustedError{Attempts: result.Attempts} + } + } + + return nil, &FallbackExhaustedError{Attempts: result.Attempts} +} + +// FallbackExhaustedError indicates all fallback candidates were tried and failed. +type FallbackExhaustedError struct { + Attempts []FallbackAttempt +} + +func (e *FallbackExhaustedError) Error() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("fallback: all %d candidates failed:", len(e.Attempts))) + for i, a := range e.Attempts { + if a.Skipped { + sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model)) + } else { + sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v (reason=%s, %s)", + i+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond))) + } + } + return sb.String() +} diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go new file mode 100644 index 000000000..ea81e0d48 --- /dev/null +++ b/pkg/providers/fallback_test.go @@ -0,0 +1,473 @@ +package providers + +import ( + "context" + "errors" + "testing" + "time" +) + +func makeCandidate(provider, model string) FallbackCandidate { + return FallbackCandidate{Provider: provider, Model: model} +} + +func successRun(content string) func(ctx context.Context, provider, model string) (*LLMResponse, error) { + return func(ctx context.Context, provider, model string) (*LLMResponse, error) { + return &LLMResponse{Content: content, FinishReason: "stop"}, nil + } +} + +func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) { + return func(ctx context.Context, provider, model string) (*LLMResponse, error) { + return nil, err + } +} + +func TestFallback_SingleCandidate_Success(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} + result, err := fc.Execute(context.Background(), candidates, successRun("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Response.Content != "hello" { + t.Errorf("content = %q, want hello", result.Response.Content) + } + if result.Provider != "openai" || result.Model != "gpt-4" { + t.Errorf("provider/model = %s/%s, want openai/gpt-4", result.Provider, result.Model) + } +} + +func TestFallback_SecondCandidateSuccess(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4"), + makeCandidate("anthropic", "claude-opus"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + return nil, errors.New("rate limit exceeded") + } + return &LLMResponse{Content: "from claude", FinishReason: "stop"}, nil + } + + result, err := fc.Execute(context.Background(), candidates, run) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Provider != "anthropic" { + t.Errorf("provider = %q, want anthropic", result.Provider) + } + if result.Response.Content != "from claude" { + t.Errorf("content = %q, want 'from claude'", result.Response.Content) + } + if len(result.Attempts) != 1 { + t.Errorf("attempts = %d, want 1 (failed attempt recorded)", len(result.Attempts)) + } +} + +func TestFallback_AllFail(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4"), + makeCandidate("anthropic", "claude"), + makeCandidate("groq", "llama"), + } + + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + return nil, errors.New("rate limit exceeded") + } + + _, err := fc.Execute(context.Background(), candidates, run) + if err == nil { + t.Fatal("expected error when all candidates fail") + } + var exhausted *FallbackExhaustedError + if !errors.As(err, &exhausted) { + t.Errorf("expected FallbackExhaustedError, got %T: %v", err, err) + } + if len(exhausted.Attempts) != 3 { + t.Errorf("attempts = %d, want 3", len(exhausted.Attempts)) + } +} + +func TestFallback_ContextCanceled(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + ctx, cancel := context.WithCancel(context.Background()) + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4"), + makeCandidate("anthropic", "claude"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + cancel() // cancel context + return nil, context.Canceled + } + t.Error("should not reach second candidate after cancel") + return nil, nil + } + + _, err := fc.Execute(ctx, candidates, run) + if err != context.Canceled { + t.Errorf("expected context.Canceled, got %v", err) + } +} + +func TestFallback_NonRetriableError(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4"), + makeCandidate("anthropic", "claude"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + return nil, errors.New("string should match pattern") + } + + _, err := fc.Execute(context.Background(), candidates, run) + if err == nil { + t.Fatal("expected error for non-retriable") + } + var fe *FailoverError + if !errors.As(err, &fe) { + t.Fatalf("expected FailoverError, got %T", err) + } + if fe.Reason != FailoverFormat { + t.Errorf("reason = %q, want format", fe.Reason) + } + if attempt != 1 { + t.Errorf("attempt = %d, want 1 (non-retriable should not try next)", attempt) + } +} + +func TestFallback_CooldownSkip(t *testing.T) { + now := time.Now() + ct, _ := newTestTracker(now) + fc := NewFallbackChain(ct) + + // Put openai in cooldown + ct.MarkFailure("openai", FailoverRateLimit) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4"), + makeCandidate("anthropic", "claude"), + } + + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + if provider == "openai" { + t.Error("should not call openai (in cooldown)") + } + return &LLMResponse{Content: "claude response", FinishReason: "stop"}, nil + } + + result, err := fc.Execute(context.Background(), candidates, run) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Provider != "anthropic" { + t.Errorf("provider = %q, want anthropic", result.Provider) + } + // Should have 1 skipped attempt + skipped := 0 + for _, a := range result.Attempts { + if a.Skipped { + skipped++ + } + } + if skipped != 1 { + t.Errorf("skipped = %d, want 1", skipped) + } +} + +func TestFallback_AllInCooldown(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + // Put all providers in cooldown + ct.MarkFailure("openai", FailoverRateLimit) + ct.MarkFailure("anthropic", FailoverBilling) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4"), + makeCandidate("anthropic", "claude"), + } + + _, err := fc.Execute(context.Background(), candidates, + func(ctx context.Context, provider, model string) (*LLMResponse, error) { + t.Error("should not call any provider (all in cooldown)") + return nil, nil + }) + + if err == nil { + t.Fatal("expected error when all in cooldown") + } + var exhausted *FallbackExhaustedError + if !errors.As(err, &exhausted) { + t.Fatalf("expected FallbackExhaustedError, got %T", err) + } +} + +func TestFallback_NoCandidates(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + _, err := fc.Execute(context.Background(), nil, successRun("ok")) + if err == nil { + t.Error("expected error for empty candidates") + } +} + +func TestFallback_EmptyFallbacks(t *testing.T) { + // Single primary, no fallbacks: should work like direct call + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} + result, err := fc.Execute(context.Background(), candidates, successRun("ok")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Response.Content != "ok" { + t.Error("expected success with single candidate") + } +} + +func TestFallback_UnclassifiedError(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4"), + makeCandidate("anthropic", "claude"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + return nil, errors.New("completely unknown internal error") + } + + _, err := fc.Execute(context.Background(), candidates, run) + if err == nil { + t.Fatal("expected error for unclassified error") + } + if attempt != 1 { + t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt) + } +} + +func TestFallback_SuccessResetsCooldown(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere + } + return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil + } + + _, err := fc.Execute(context.Background(), candidates, run) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ct.IsAvailable("openai") { + t.Error("success should reset cooldown") + } +} + +// --- Image Fallback Tests --- + +func TestImageFallback_Success(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")} + result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Response.Content != "image result" { + t.Error("expected image result") + } +} + +func TestImageFallback_DimensionError(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4o"), + makeCandidate("anthropic", "claude"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + return nil, errors.New("image dimensions exceed max 4096x4096") + } + + _, err := fc.ExecuteImage(context.Background(), candidates, run) + if err == nil { + t.Fatal("expected error for image dimension error") + } + if attempt != 1 { + t.Errorf("attempt = %d, want 1 (image dimension error should not retry)", attempt) + } +} + +func TestImageFallback_SizeError(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4o"), + makeCandidate("anthropic", "claude"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + return nil, errors.New("image exceeds 20 mb") + } + + _, err := fc.ExecuteImage(context.Background(), candidates, run) + if err == nil { + t.Fatal("expected error for image size error") + } + if attempt != 1 { + t.Errorf("attempt = %d, want 1 (image size error should not retry)", attempt) + } +} + +func TestImageFallback_RetryOnOtherErrors(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + candidates := []FallbackCandidate{ + makeCandidate("openai", "gpt-4o"), + makeCandidate("anthropic", "claude-sonnet"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + return nil, errors.New("rate limit exceeded") + } + return &LLMResponse{Content: "image ok", FinishReason: "stop"}, nil + } + + result, err := fc.ExecuteImage(context.Background(), candidates, run) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Provider != "anthropic" { + t.Errorf("provider = %q, want anthropic", result.Provider) + } +} + +func TestImageFallback_NoCandidates(t *testing.T) { + ct := NewCooldownTracker() + fc := NewFallbackChain(ct) + + _, err := fc.ExecuteImage(context.Background(), nil, successRun("ok")) + if err == nil { + t.Error("expected error for empty candidates") + } +} + +// --- ResolveCandidates Tests --- + +func TestResolveCandidates_Simple(t *testing.T) { + cfg := ModelConfig{ + Primary: "gpt-4", + Fallbacks: []string{"anthropic/claude-opus", "groq/llama-3"}, + } + + candidates := ResolveCandidates(cfg, "openai") + if len(candidates) != 3 { + t.Fatalf("candidates = %d, want 3", len(candidates)) + } + + if candidates[0].Provider != "openai" || candidates[0].Model != "gpt-4" { + t.Errorf("candidate[0] = %s/%s, want openai/gpt-4", candidates[0].Provider, candidates[0].Model) + } + if candidates[1].Provider != "anthropic" || candidates[1].Model != "claude-opus" { + t.Errorf("candidate[1] = %s/%s, want anthropic/claude-opus", candidates[1].Provider, candidates[1].Model) + } + if candidates[2].Provider != "groq" || candidates[2].Model != "llama-3" { + t.Errorf("candidate[2] = %s/%s, want groq/llama-3", candidates[2].Provider, candidates[2].Model) + } +} + +func TestResolveCandidates_Deduplication(t *testing.T) { + cfg := ModelConfig{ + Primary: "openai/gpt-4", + Fallbacks: []string{"openai/gpt-4", "anthropic/claude"}, + } + + candidates := ResolveCandidates(cfg, "default") + if len(candidates) != 2 { + t.Errorf("candidates = %d, want 2 (duplicate removed)", len(candidates)) + } +} + +func TestResolveCandidates_EmptyFallbacks(t *testing.T) { + cfg := ModelConfig{ + Primary: "gpt-4", + Fallbacks: nil, + } + + candidates := ResolveCandidates(cfg, "openai") + if len(candidates) != 1 { + t.Errorf("candidates = %d, want 1", len(candidates)) + } +} + +func TestResolveCandidates_EmptyPrimary(t *testing.T) { + cfg := ModelConfig{ + Primary: "", + Fallbacks: []string{"anthropic/claude"}, + } + + candidates := ResolveCandidates(cfg, "openai") + if len(candidates) != 1 { + t.Errorf("candidates = %d, want 1", len(candidates)) + } +} + +func TestFallbackExhaustedError_Message(t *testing.T) { + e := &FallbackExhaustedError{ + Attempts: []FallbackAttempt{ + {Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond}, + {Provider: "anthropic", Model: "claude", Skipped: true}, + }, + } + msg := e.Error() + if msg == "" { + t.Error("expected non-empty error message") + } +} diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go new file mode 100644 index 000000000..5058819f5 --- /dev/null +++ b/pkg/providers/github_copilot_provider.go @@ -0,0 +1,82 @@ +package providers + +import ( + "context" + "fmt" + + json "encoding/json" + + copilot "github.com/github/copilot-sdk/go" +) + +type GitHubCopilotProvider struct { + uri string + connectMode string // `stdio` or `grpc`` + + session *copilot.Session +} + +func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { + + var session *copilot.Session + if connectMode == "" { + connectMode = "grpc" + } + switch connectMode { + + case "stdio": + //todo + case "grpc": + client := copilot.NewClient(&copilot.ClientOptions{ + CLIUrl: uri, + }) + if err := client.Start(context.Background()); err != nil { + return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details") + } + defer client.Stop() + session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{ + Model: model, + Hooks: &copilot.SessionHooks{}, + }) + + } + + return &GitHubCopilotProvider{ + uri: uri, + connectMode: connectMode, + session: session, + }, nil +} + +// Chat sends a chat request to GitHub Copilot +func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + type tempMessage struct { + Role string `json:"role"` + Content string `json:"content"` + } + out := make([]tempMessage, 0, len(messages)) + + for _, msg := range messages { + out = append(out, tempMessage{ + Role: msg.Role, + Content: msg.Content, + }) + } + + fullcontent, _ := json.Marshal(out) + + content, _ := p.session.Send(ctx, copilot.MessageOptions{ + Prompt: string(fullcontent), + }) + + return &LLMResponse{ + FinishReason: "stop", + Content: content, + }, nil + +} + +func (p *GitHubCopilotProvider) GetDefaultModel() string { + + return "gpt-4.1" +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index f63c68c0b..967d089d5 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -7,272 +7,25 @@ package providers import ( - "bytes" "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) type HTTPProvider struct { - apiKey string - apiBase string - httpClient *http.Client + delegate *openai_compat.Provider } -func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider { +func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { return &HTTPProvider{ - apiKey: apiKey, - apiBase: apiBase, - httpClient: &http.Client{ - Timeout: 0, - }, + delegate: openai_compat.NewProvider(apiKey, apiBase, proxy), } } func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - if p.apiBase == "" { - return nil, fmt.Errorf("API base not configured") - } - - requestBody := map[string]interface{}{ - "model": model, - "messages": messages, - } - - if len(tools) > 0 { - requestBody["tools"] = tools - requestBody["tool_choice"] = "auto" - } - - if maxTokens, ok := options["max_tokens"].(int); ok { - lowerModel := strings.ToLower(model) - if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { - requestBody["max_completion_tokens"] = maxTokens - } else { - requestBody["max_tokens"] = maxTokens - } - } - - if temperature, ok := options["temperature"].(float64); ok { - requestBody["temperature"] = temperature - } - - jsonData, err := json.Marshal(requestBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - if p.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+p.apiKey) - } - - resp, err := p.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API error: %s", string(body)) - } - - return p.parseResponse(body) -} - -func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) { - var apiResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function *struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *UsageInfo `json:"usage"` - } - - if err := json.Unmarshal(body, &apiResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - if len(apiResponse.Choices) == 0 { - return &LLMResponse{ - Content: "", - FinishReason: "stop", - }, nil - } - - choice := apiResponse.Choices[0] - - toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) - for _, tc := range choice.Message.ToolCalls { - arguments := make(map[string]interface{}) - name := "" - - // Handle OpenAI format with nested function object - if tc.Type == "function" && tc.Function != nil { - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - arguments["raw"] = tc.Function.Arguments - } - } - } else if tc.Function != nil { - // Legacy format without type field - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - arguments["raw"] = tc.Function.Arguments - } - } - } - - toolCalls = append(toolCalls, ToolCall{ - ID: tc.ID, - Name: name, - Arguments: arguments, - }) - } - - return &LLMResponse{ - Content: choice.Message.Content, - ToolCalls: toolCalls, - FinishReason: choice.FinishReason, - Usage: apiResponse.Usage, - }, nil + return p.delegate.Chat(ctx, messages, tools, model, options) } func (p *HTTPProvider) GetDefaultModel() string { return "" } - -func createClaudeAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("anthropic") - if err != nil { - return nil, fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") - } - return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil -} - -func createCodexAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("openai") - if err != nil { - return nil, fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") - } - return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil -} - -func CreateProvider(cfg *config.Config) (LLMProvider, error) { - model := cfg.Agents.Defaults.Model - - var apiKey, apiBase string - - lowerModel := strings.ToLower(model) - - switch { - case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): - apiKey = cfg.Providers.OpenRouter.APIKey - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - - case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - return createClaudeAuthProvider() - } - apiKey = cfg.Providers.Anthropic.APIKey - apiBase = cfg.Providers.Anthropic.APIBase - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } - - case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() - } - apiKey = cfg.Providers.OpenAI.APIKey - apiBase = cfg.Providers.OpenAI.APIBase - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - - case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": - apiKey = cfg.Providers.Gemini.APIKey - apiBase = cfg.Providers.Gemini.APIBase - if apiBase == "" { - apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - - case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": - apiKey = cfg.Providers.Zhipu.APIKey - apiBase = cfg.Providers.Zhipu.APIBase - if apiBase == "" { - apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - - case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": - apiKey = cfg.Providers.Groq.APIKey - apiBase = cfg.Providers.Groq.APIBase - if apiBase == "" { - apiBase = "https://api.groq.com/openai/v1" - } - - case cfg.Providers.VLLM.APIBase != "": - apiKey = cfg.Providers.VLLM.APIKey - apiBase = cfg.Providers.VLLM.APIBase - - default: - if cfg.Providers.OpenRouter.APIKey != "" { - apiKey = cfg.Providers.OpenRouter.APIKey - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - } else { - return nil, fmt.Errorf("no API key configured for model: %s", model) - } - } - - if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { - return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) - } - - if apiBase == "" { - return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) - } - - return NewHTTPProvider(apiKey, apiBase), nil -} \ No newline at end of file diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go new file mode 100644 index 000000000..0d1b02d16 --- /dev/null +++ b/pkg/providers/model_ref.go @@ -0,0 +1,64 @@ +package providers + +import "strings" + +// ModelRef represents a parsed model reference with provider and model name. +type ModelRef struct { + Provider string + Model string +} + +// ParseModelRef parses "anthropic/claude-opus" into {Provider: "anthropic", Model: "claude-opus"}. +// If no slash present, uses defaultProvider. +// Returns nil for empty input. +func ParseModelRef(raw string, defaultProvider string) *ModelRef { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + if idx := strings.Index(raw, "/"); idx > 0 { + provider := NormalizeProvider(raw[:idx]) + model := strings.TrimSpace(raw[idx+1:]) + if model == "" { + return nil + } + return &ModelRef{Provider: provider, Model: model} + } + + return &ModelRef{ + Provider: NormalizeProvider(defaultProvider), + Model: raw, + } +} + +// NormalizeProvider normalizes provider identifiers to canonical form. +func NormalizeProvider(provider string) string { + p := strings.ToLower(strings.TrimSpace(provider)) + + switch p { + case "z.ai", "z-ai": + return "zai" + case "opencode-zen": + return "opencode" + case "qwen": + return "qwen-portal" + case "kimi-code": + return "kimi-coding" + case "gpt": + return "openai" + case "claude": + return "anthropic" + case "glm": + return "zhipu" + case "google": + return "gemini" + } + + return p +} + +// ModelKey returns a canonical "provider/model" key for deduplication. +func ModelKey(provider, model string) string { + return NormalizeProvider(provider) + "/" + strings.ToLower(strings.TrimSpace(model)) +} diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go new file mode 100644 index 000000000..6dd25167f --- /dev/null +++ b/pkg/providers/model_ref_test.go @@ -0,0 +1,125 @@ +package providers + +import "testing" + +func TestParseModelRef_WithSlash(t *testing.T) { + ref := ParseModelRef("anthropic/claude-opus", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "anthropic" { + t.Errorf("provider = %q, want anthropic", ref.Provider) + } + if ref.Model != "claude-opus" { + t.Errorf("model = %q, want claude-opus", ref.Model) + } +} + +func TestParseModelRef_WithoutSlash(t *testing.T) { + ref := ParseModelRef("gpt-4", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "openai" { + t.Errorf("provider = %q, want openai", ref.Provider) + } + if ref.Model != "gpt-4" { + t.Errorf("model = %q, want gpt-4", ref.Model) + } +} + +func TestParseModelRef_Empty(t *testing.T) { + ref := ParseModelRef("", "openai") + if ref != nil { + t.Errorf("expected nil for empty string, got %+v", ref) + } +} + +func TestParseModelRef_EmptyModelAfterSlash(t *testing.T) { + ref := ParseModelRef("openai/", "default") + if ref != nil { + t.Errorf("expected nil for empty model, got %+v", ref) + } +} + +func TestParseModelRef_WhitespaceHandling(t *testing.T) { + ref := ParseModelRef(" anthropic / claude-opus ", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "anthropic" { + t.Errorf("provider = %q, want anthropic", ref.Provider) + } + if ref.Model != "claude-opus" { + t.Errorf("model = %q, want claude-opus", ref.Model) + } +} + +func TestNormalizeProvider(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"OpenAI", "openai"}, + {"ANTHROPIC", "anthropic"}, + {"z.ai", "zai"}, + {"z-ai", "zai"}, + {"Z.AI", "zai"}, + {"opencode-zen", "opencode"}, + {"qwen", "qwen-portal"}, + {"kimi-code", "kimi-coding"}, + {"gpt", "openai"}, + {"claude", "anthropic"}, + {"glm", "zhipu"}, + {"google", "gemini"}, + {"groq", "groq"}, + {"", ""}, + } + + for _, tt := range tests { + got := NormalizeProvider(tt.input) + if got != tt.want { + t.Errorf("NormalizeProvider(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestModelKey(t *testing.T) { + tests := []struct { + provider string + model string + want string + }{ + {"openai", "gpt-4", "openai/gpt-4"}, + {"Anthropic", "Claude-Opus", "anthropic/claude-opus"}, + {"claude", "sonnet", "anthropic/sonnet"}, + {"z.ai", "Model-X", "zai/model-x"}, + } + + for _, tt := range tests { + got := ModelKey(tt.provider, tt.model) + if got != tt.want { + t.Errorf("ModelKey(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want) + } + } +} + +func TestParseModelRef_ProviderNormalization(t *testing.T) { + ref := ParseModelRef("Z.AI/model-x", "default") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "zai" { + t.Errorf("provider = %q, want zai", ref.Provider) + } +} + +func TestParseModelRef_DefaultProviderNormalization(t *testing.T) { + ref := ParseModelRef("gpt-4o", "GPT") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "openai" { + t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider) + } +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go new file mode 100644 index 000000000..9b404dd77 --- /dev/null +++ b/pkg/providers/openai_compat/provider.go @@ -0,0 +1,232 @@ +package openai_compat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + +type Provider struct { + apiKey string + apiBase string + httpClient *http.Client +} + +func NewProvider(apiKey, apiBase, proxy string) *Provider { + client := &http.Client{ + Timeout: 120 * time.Second, + } + + if proxy != "" { + parsed, err := url.Parse(proxy) + if err == nil { + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(parsed), + } + } else { + log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err) + } + } + + return &Provider{ + apiKey: apiKey, + apiBase: strings.TrimRight(apiBase, "/"), + httpClient: client, + } +} + +func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + model = normalizeModel(model, p.apiBase) + + requestBody := map[string]interface{}{ + "model": model, + "messages": messages, + } + + if len(tools) > 0 { + requestBody["tools"] = tools + requestBody["tool_choice"] = "auto" + } + + if maxTokens, ok := asInt(options["max_tokens"]); ok { + lowerModel := strings.ToLower(model) + if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { + requestBody["max_completion_tokens"] = maxTokens + } else { + requestBody["max_tokens"] = maxTokens + } + } + + if temperature, ok := asFloat(options["temperature"]); ok { + lowerModel := strings.ToLower(model) + // Kimi k2 models only support temperature=1. + if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { + requestBody["temperature"] = 1.0 + } else { + requestBody["temperature"] = temperature + } + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body)) + } + + return parseResponse(body) +} + +func parseResponse(body []byte) (*LLMResponse, error) { + var apiResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *UsageInfo `json:"usage"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(apiResponse.Choices) == 0 { + return &LLMResponse{ + Content: "", + FinishReason: "stop", + }, nil + } + + choice := apiResponse.Choices[0] + toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) + for _, tc := range choice.Message.ToolCalls { + arguments := make(map[string]interface{}) + name := "" + + if tc.Function != nil { + name = tc.Function.Name + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { + log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) + arguments["raw"] = tc.Function.Arguments + } + } + } + + toolCalls = append(toolCalls, ToolCall{ + ID: tc.ID, + Name: name, + Arguments: arguments, + }) + } + + return &LLMResponse{ + Content: choice.Message.Content, + ToolCalls: toolCalls, + FinishReason: choice.FinishReason, + Usage: apiResponse.Usage, + }, nil +} + +func normalizeModel(model, apiBase string) string { + idx := strings.Index(model, "/") + if idx == -1 { + return model + } + + if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") { + return model + } + + prefix := strings.ToLower(model[:idx]) + switch prefix { + case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu": + return model[idx+1:] + default: + return model + } +} + +func asInt(v interface{}) (int, bool) { + switch val := v.(type) { + case int: + return val, true + case int64: + return int(val), true + case float64: + return int(val), true + case float32: + return int(val), true + default: + return 0, false + } +} + +func asFloat(v interface{}) (float64, bool) { + switch val := v.(type) { + case float64: + return val, true + case float32: + return float64(val), true + case int: + return float64(val), true + case int64: + return float64(val), true + default: + return 0, false + } +} diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go new file mode 100644 index 000000000..94779b39c --- /dev/null +++ b/pkg/providers/openai_compat/provider_test.go @@ -0,0 +1,277 @@ +package openai_compat + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { + var requestBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234}) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if _, ok := requestBody["max_completion_tokens"]; !ok { + t.Fatalf("expected max_completion_tokens in request body") + } + if _, ok := requestBody["max_tokens"]; ok { + t.Fatalf("did not expect max_tokens key for glm model") + } +} + +func TestProviderChat_ParsesToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{ + "content": "", + "tool_calls": []map[string]interface{}{ + { + "id": "call_1", + "type": "function", + "function": map[string]interface{}{ + "name": "get_weather", + "arguments": "{\"city\":\"SF\"}", + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + "usage": map[string]interface{}{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "get_weather" { + t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") + } + if out.ToolCalls[0].Arguments["city"] != "SF" { + t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) + } +} + +func TestProviderChat_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "bad request", http.StatusBadRequest) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { + var requestBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "moonshot/kimi-k2.5", + map[string]interface{}{"temperature": 0.3}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["model"] != "kimi-k2.5" { + t.Fatalf("model = %v, want kimi-k2.5", requestBody["model"]) + } + if requestBody["temperature"] != 1.0 { + t.Fatalf("temperature = %v, want 1.0", requestBody["temperature"]) + } +} + +func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { + tests := []struct { + name string + input string + wantModel string + }{ + { + name: "strips groq prefix and keeps nested model", + input: "groq/openai/gpt-oss-120b", + wantModel: "openai/gpt-oss-120b", + }, + { + name: "strips ollama prefix", + input: "ollama/qwen2.5:14b", + wantModel: "qwen2.5:14b", + }, + { + name: "strips deepseek prefix", + input: "deepseek/deepseek-chat", + wantModel: "deepseek-chat", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requestBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["model"] != tt.wantModel { + t.Fatalf("model = %v, want %s", requestBody["model"], tt.wantModel) + } + }) + } +} + +func TestProvider_ProxyConfigured(t *testing.T) { + proxyURL := "http://127.0.0.1:8080" + p := NewProvider("key", "https://example.com", proxyURL) + + transport, ok := p.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http transport with proxy, got %T", p.httpClient.Transport) + } + + req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} + gotProxy, err := transport.Proxy(req) + if err != nil { + t.Fatalf("proxy function returned error: %v", err) + } + if gotProxy == nil || gotProxy.String() != proxyURL { + t.Fatalf("proxy = %v, want %s", gotProxy, proxyURL) + } +} + +func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) { + var requestBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + map[string]interface{}{"max_tokens": float64(512), "temperature": 1}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["max_tokens"] != float64(512) { + t.Fatalf("max_tokens = %v, want 512", requestBody["max_tokens"]) + } + if requestBody["temperature"] != float64(1) { + t.Fatalf("temperature = %v, want 1", requestBody["temperature"]) + } +} + +func TestNormalizeModel_UsesAPIBase(t *testing.T) { + if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" { + t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat") + } + if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { + t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") + } +} diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go new file mode 100644 index 000000000..6b33ae734 --- /dev/null +++ b/pkg/providers/protocoltypes/types.go @@ -0,0 +1,45 @@ +package protocoltypes + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type LLMResponse struct { + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + Usage *UsageInfo `json:"usage,omitempty"` +} + +type UsageInfo struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolDefinition struct { + Type string `json:"type"` + Function ToolFunctionDefinition `json:"function"` +} + +type ToolFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go new file mode 100644 index 000000000..97a219283 --- /dev/null +++ b/pkg/providers/tool_call_extract.go @@ -0,0 +1,72 @@ +package providers + +import ( + "encoding/json" + "strings" +) + +// extractToolCallsFromText parses tool call JSON from response text. +// Both ClaudeCliProvider and CodexCliProvider use this to extract +// tool calls that the model outputs in its response text. +func extractToolCallsFromText(text string) []ToolCall { + start := strings.Index(text, `{"tool_calls"`) + if start == -1 { + return nil + } + + end := findMatchingBrace(text, start) + if end == start { + return nil + } + + jsonStr := text[start:end] + + var wrapper struct { + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } + + if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil { + return nil + } + + var result []ToolCall + for _, tc := range wrapper.ToolCalls { + var args map[string]interface{} + json.Unmarshal([]byte(tc.Function.Arguments), &args) + + result = append(result, ToolCall{ + ID: tc.ID, + Type: tc.Type, + Name: tc.Function.Name, + Arguments: args, + Function: &FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + + return result +} + +// stripToolCallsFromText removes tool call JSON from response text. +func stripToolCallsFromText(text string) string { + start := strings.Index(text, `{"tool_calls"`) + if start == -1 { + return text + } + + end := findMatchingBrace(text, start) + if end == start { + return text + } + + return strings.TrimSpace(text[:start] + text[end:]) +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 88b62e975..c4a9de58a 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -1,52 +1,64 @@ package providers -import "context" +import ( + "context" + "fmt" -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` -} + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -type LLMResponse struct { - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - Usage *UsageInfo `json:"usage,omitempty"` -} - -type UsageInfo struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} +type ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition type LLMProvider interface { Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) GetDefaultModel() string } -type ToolDefinition struct { - Type string `json:"type"` - Function ToolFunctionDefinition `json:"function"` +// FailoverReason classifies why an LLM request failed for fallback decisions. +type FailoverReason string + +const ( + FailoverAuth FailoverReason = "auth" + FailoverRateLimit FailoverReason = "rate_limit" + FailoverBilling FailoverReason = "billing" + FailoverTimeout FailoverReason = "timeout" + FailoverFormat FailoverReason = "format" + FailoverOverloaded FailoverReason = "overloaded" + FailoverUnknown FailoverReason = "unknown" +) + +// FailoverError wraps an LLM provider error with classification metadata. +type FailoverError struct { + Reason FailoverReason + Provider string + Model string + Status int + Wrapped error } -type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` +func (e *FailoverError) Error() string { + return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v", + e.Reason, e.Provider, e.Model, e.Status, e.Wrapped) +} + +func (e *FailoverError) Unwrap() error { + return e.Wrapped +} + +// IsRetriable returns true if this error should trigger fallback to next candidate. +// Non-retriable: Format errors (bad request structure, image dimension/size). +func (e *FailoverError) IsRetriable() bool { + return e.Reason != FailoverFormat +} + +// ModelConfig holds primary model and fallback list. +type ModelConfig struct { + Primary string + Fallbacks []string } diff --git a/pkg/routing/agent_id.go b/pkg/routing/agent_id.go new file mode 100644 index 000000000..bcf2f0dc0 --- /dev/null +++ b/pkg/routing/agent_id.go @@ -0,0 +1,66 @@ +package routing + +import ( + "regexp" + "strings" +) + +const ( + DefaultAgentID = "main" + DefaultMainKey = "main" + DefaultAccountID = "default" + MaxAgentIDLength = 64 +) + +var ( + validIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) + invalidCharsRe = regexp.MustCompile(`[^a-z0-9_-]+`) + leadingDashRe = regexp.MustCompile(`^-+`) + trailingDashRe = regexp.MustCompile(`-+$`) +) + +// NormalizeAgentID sanitizes an agent ID to [a-z0-9][a-z0-9_-]{0,63}. +// Invalid characters are collapsed to "-". Leading/trailing dashes stripped. +// Empty input returns DefaultAgentID ("main"). +func NormalizeAgentID(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return DefaultAgentID + } + lower := strings.ToLower(trimmed) + if validIDRe.MatchString(lower) { + return lower + } + result := invalidCharsRe.ReplaceAllString(lower, "-") + result = leadingDashRe.ReplaceAllString(result, "") + result = trailingDashRe.ReplaceAllString(result, "") + if len(result) > MaxAgentIDLength { + result = result[:MaxAgentIDLength] + } + if result == "" { + return DefaultAgentID + } + return result +} + +// NormalizeAccountID sanitizes an account ID. Empty returns DefaultAccountID. +func NormalizeAccountID(id string) string { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return DefaultAccountID + } + lower := strings.ToLower(trimmed) + if validIDRe.MatchString(lower) { + return lower + } + result := invalidCharsRe.ReplaceAllString(lower, "-") + result = leadingDashRe.ReplaceAllString(result, "") + result = trailingDashRe.ReplaceAllString(result, "") + if len(result) > MaxAgentIDLength { + result = result[:MaxAgentIDLength] + } + if result == "" { + return DefaultAccountID + } + return result +} diff --git a/pkg/routing/agent_id_test.go b/pkg/routing/agent_id_test.go new file mode 100644 index 000000000..050fe0645 --- /dev/null +++ b/pkg/routing/agent_id_test.go @@ -0,0 +1,86 @@ +package routing + +import "testing" + +func TestNormalizeAgentID_Empty(t *testing.T) { + if got := NormalizeAgentID(""); got != DefaultAgentID { + t.Errorf("NormalizeAgentID('') = %q, want %q", got, DefaultAgentID) + } +} + +func TestNormalizeAgentID_Whitespace(t *testing.T) { + if got := NormalizeAgentID(" "); got != DefaultAgentID { + t.Errorf("NormalizeAgentID(' ') = %q, want %q", got, DefaultAgentID) + } +} + +func TestNormalizeAgentID_Valid(t *testing.T) { + tests := []struct { + input, want string + }{ + {"main", "main"}, + {"Main", "main"}, + {"SALES", "sales"}, + {"support-bot", "support-bot"}, + {"agent_1", "agent_1"}, + {"a", "a"}, + {"0test", "0test"}, + } + for _, tt := range tests { + if got := NormalizeAgentID(tt.input); got != tt.want { + t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestNormalizeAgentID_InvalidChars(t *testing.T) { + tests := []struct { + input, want string + }{ + {"Hello World", "hello-world"}, + {"agent@123", "agent-123"}, + {"foo.bar.baz", "foo-bar-baz"}, + {"--leading", "leading"}, + {"--both--", "both"}, + } + for _, tt := range tests { + if got := NormalizeAgentID(tt.input); got != tt.want { + t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestNormalizeAgentID_AllInvalid(t *testing.T) { + if got := NormalizeAgentID("@@@"); got != DefaultAgentID { + t.Errorf("NormalizeAgentID('@@@') = %q, want %q", got, DefaultAgentID) + } +} + +func TestNormalizeAgentID_TruncatesAt64(t *testing.T) { + long := "" + for i := 0; i < 100; i++ { + long += "a" + } + got := NormalizeAgentID(long) + if len(got) > MaxAgentIDLength { + t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength) + } +} + +func TestNormalizeAccountID_Empty(t *testing.T) { + if got := NormalizeAccountID(""); got != DefaultAccountID { + t.Errorf("NormalizeAccountID('') = %q, want %q", got, DefaultAccountID) + } +} + +func TestNormalizeAccountID_Valid(t *testing.T) { + if got := NormalizeAccountID("MyBot"); got != "mybot" { + t.Errorf("NormalizeAccountID('MyBot') = %q, want 'mybot'", got) + } +} + +func TestNormalizeAccountID_InvalidChars(t *testing.T) { + if got := NormalizeAccountID("bot@home"); got != "bot-home" { + t.Errorf("NormalizeAccountID('bot@home') = %q, want 'bot-home'", got) + } +} diff --git a/pkg/routing/route.go b/pkg/routing/route.go new file mode 100644 index 000000000..9eb060c53 --- /dev/null +++ b/pkg/routing/route.go @@ -0,0 +1,252 @@ +package routing + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// RouteInput contains the routing context from an inbound message. +type RouteInput struct { + Channel string + AccountID string + Peer *RoutePeer + ParentPeer *RoutePeer + GuildID string + TeamID string +} + +// ResolvedRoute is the result of agent routing. +type ResolvedRoute struct { + AgentID string + Channel string + AccountID string + SessionKey string + MainSessionKey string + MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default" +} + +// RouteResolver determines which agent handles a message based on config bindings. +type RouteResolver struct { + cfg *config.Config +} + +// NewRouteResolver creates a new route resolver. +func NewRouteResolver(cfg *config.Config) *RouteResolver { + return &RouteResolver{cfg: cfg} +} + +// ResolveRoute determines which agent handles the message and constructs session keys. +// Implements the 7-level priority cascade: +// peer > parent_peer > guild > team > account > channel_wildcard > default +func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute { + channel := strings.ToLower(strings.TrimSpace(input.Channel)) + accountID := NormalizeAccountID(input.AccountID) + peer := input.Peer + + dmScope := DMScope(r.cfg.Session.DMScope) + if dmScope == "" { + dmScope = DMScopeMain + } + identityLinks := r.cfg.Session.IdentityLinks + + bindings := r.filterBindings(channel, accountID) + + choose := func(agentID string, matchedBy string) ResolvedRoute { + resolvedAgentID := r.pickAgentID(agentID) + sessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: resolvedAgentID, + Channel: channel, + AccountID: accountID, + Peer: peer, + DMScope: dmScope, + IdentityLinks: identityLinks, + })) + mainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID)) + return ResolvedRoute{ + AgentID: resolvedAgentID, + Channel: channel, + AccountID: accountID, + SessionKey: sessionKey, + MainSessionKey: mainSessionKey, + MatchedBy: matchedBy, + } + } + + // Priority 1: Peer binding + if peer != nil && strings.TrimSpace(peer.ID) != "" { + if match := r.findPeerMatch(bindings, peer); match != nil { + return choose(match.AgentID, "binding.peer") + } + } + + // Priority 2: Parent peer binding + parentPeer := input.ParentPeer + if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" { + if match := r.findPeerMatch(bindings, parentPeer); match != nil { + return choose(match.AgentID, "binding.peer.parent") + } + } + + // Priority 3: Guild binding + guildID := strings.TrimSpace(input.GuildID) + if guildID != "" { + if match := r.findGuildMatch(bindings, guildID); match != nil { + return choose(match.AgentID, "binding.guild") + } + } + + // Priority 4: Team binding + teamID := strings.TrimSpace(input.TeamID) + if teamID != "" { + if match := r.findTeamMatch(bindings, teamID); match != nil { + return choose(match.AgentID, "binding.team") + } + } + + // Priority 5: Account binding + if match := r.findAccountMatch(bindings); match != nil { + return choose(match.AgentID, "binding.account") + } + + // Priority 6: Channel wildcard binding + if match := r.findChannelWildcardMatch(bindings); match != nil { + return choose(match.AgentID, "binding.channel") + } + + // Priority 7: Default agent + return choose(r.resolveDefaultAgentID(), "default") +} + +func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding { + var filtered []config.AgentBinding + for _, b := range r.cfg.Bindings { + matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel)) + if matchChannel == "" || matchChannel != channel { + continue + } + if !matchesAccountID(b.Match.AccountID, accountID) { + continue + } + filtered = append(filtered, b) + } + return filtered +} + +func matchesAccountID(matchAccountID, actual string) bool { + trimmed := strings.TrimSpace(matchAccountID) + if trimmed == "" { + return actual == DefaultAccountID + } + if trimmed == "*" { + return true + } + return strings.ToLower(trimmed) == strings.ToLower(actual) +} + +func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding { + for i := range bindings { + b := &bindings[i] + if b.Match.Peer == nil { + continue + } + peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind)) + peerID := strings.TrimSpace(b.Match.Peer.ID) + if peerKind == "" || peerID == "" { + continue + } + if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID { + return b + } + } + return nil +} + +func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding { + for i := range bindings { + b := &bindings[i] + matchGuild := strings.TrimSpace(b.Match.GuildID) + if matchGuild != "" && matchGuild == guildID { + return &bindings[i] + } + } + return nil +} + +func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding { + for i := range bindings { + b := &bindings[i] + matchTeam := strings.TrimSpace(b.Match.TeamID) + if matchTeam != "" && matchTeam == teamID { + return &bindings[i] + } + } + return nil +} + +func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding { + for i := range bindings { + b := &bindings[i] + accountID := strings.TrimSpace(b.Match.AccountID) + if accountID == "*" { + continue + } + if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" { + continue + } + return &bindings[i] + } + return nil +} + +func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding { + for i := range bindings { + b := &bindings[i] + accountID := strings.TrimSpace(b.Match.AccountID) + if accountID != "*" { + continue + } + if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" { + continue + } + return &bindings[i] + } + return nil +} + +func (r *RouteResolver) pickAgentID(agentID string) string { + trimmed := strings.TrimSpace(agentID) + if trimmed == "" { + return NormalizeAgentID(r.resolveDefaultAgentID()) + } + normalized := NormalizeAgentID(trimmed) + agents := r.cfg.Agents.List + if len(agents) == 0 { + return normalized + } + for _, a := range agents { + if NormalizeAgentID(a.ID) == normalized { + return normalized + } + } + return NormalizeAgentID(r.resolveDefaultAgentID()) +} + +func (r *RouteResolver) resolveDefaultAgentID() string { + agents := r.cfg.Agents.List + if len(agents) == 0 { + return DefaultAgentID + } + for _, a := range agents { + if a.Default { + id := strings.TrimSpace(a.ID) + if id != "" { + return NormalizeAgentID(id) + } + } + } + if id := strings.TrimSpace(agents[0].ID); id != "" { + return NormalizeAgentID(id) + } + return DefaultAgentID +} diff --git a/pkg/routing/route_test.go b/pkg/routing/route_test.go new file mode 100644 index 000000000..8255db5f9 --- /dev/null +++ b/pkg/routing/route_test.go @@ -0,0 +1,297 @@ +package routing + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config { + return &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: "/tmp/picoclaw-test", + Model: "gpt-4", + }, + List: agents, + }, + Bindings: bindings, + Session: config.SessionConfig{ + DMScope: "per-peer", + }, + } +} + +func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) { + cfg := testConfig(nil, nil) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user1"}, + }) + + if route.AgentID != DefaultAgentID { + t.Errorf("AgentID = %q, want %q", route.AgentID, DefaultAgentID) + } + if route.MatchedBy != "default" { + t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy) + } +} + +func TestResolveRoute_PeerBinding(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "sales", Default: true}, + {ID: "support"}, + } + bindings := []config.AgentBinding{ + { + AgentID: "support", + Match: config.BindingMatch{ + Channel: "telegram", + AccountID: "*", + Peer: &config.PeerMatch{Kind: "direct", ID: "user123"}, + }, + }, + } + cfg := testConfig(agents, bindings) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + }) + + if route.AgentID != "support" { + t.Errorf("AgentID = %q, want 'support'", route.AgentID) + } + if route.MatchedBy != "binding.peer" { + t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy) + } +} + +func TestResolveRoute_GuildBinding(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "general", Default: true}, + {ID: "gaming"}, + } + bindings := []config.AgentBinding{ + { + AgentID: "gaming", + Match: config.BindingMatch{ + Channel: "discord", + AccountID: "*", + GuildID: "guild-abc", + }, + }, + } + cfg := testConfig(agents, bindings) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "discord", + GuildID: "guild-abc", + Peer: &RoutePeer{Kind: "channel", ID: "ch1"}, + }) + + if route.AgentID != "gaming" { + t.Errorf("AgentID = %q, want 'gaming'", route.AgentID) + } + if route.MatchedBy != "binding.guild" { + t.Errorf("MatchedBy = %q, want 'binding.guild'", route.MatchedBy) + } +} + +func TestResolveRoute_TeamBinding(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "general", Default: true}, + {ID: "work"}, + } + bindings := []config.AgentBinding{ + { + AgentID: "work", + Match: config.BindingMatch{ + Channel: "slack", + AccountID: "*", + TeamID: "T12345", + }, + }, + } + cfg := testConfig(agents, bindings) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "slack", + TeamID: "T12345", + Peer: &RoutePeer{Kind: "channel", ID: "C001"}, + }) + + if route.AgentID != "work" { + t.Errorf("AgentID = %q, want 'work'", route.AgentID) + } + if route.MatchedBy != "binding.team" { + t.Errorf("MatchedBy = %q, want 'binding.team'", route.MatchedBy) + } +} + +func TestResolveRoute_AccountBinding(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "default-agent", Default: true}, + {ID: "premium"}, + } + bindings := []config.AgentBinding{ + { + AgentID: "premium", + Match: config.BindingMatch{ + Channel: "telegram", + AccountID: "bot2", + }, + }, + } + cfg := testConfig(agents, bindings) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "telegram", + AccountID: "bot2", + Peer: &RoutePeer{Kind: "direct", ID: "user1"}, + }) + + if route.AgentID != "premium" { + t.Errorf("AgentID = %q, want 'premium'", route.AgentID) + } + if route.MatchedBy != "binding.account" { + t.Errorf("MatchedBy = %q, want 'binding.account'", route.MatchedBy) + } +} + +func TestResolveRoute_ChannelWildcard(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "main", Default: true}, + {ID: "telegram-bot"}, + } + bindings := []config.AgentBinding{ + { + AgentID: "telegram-bot", + Match: config.BindingMatch{ + Channel: "telegram", + AccountID: "*", + }, + }, + } + cfg := testConfig(agents, bindings) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user1"}, + }) + + if route.AgentID != "telegram-bot" { + t.Errorf("AgentID = %q, want 'telegram-bot'", route.AgentID) + } + if route.MatchedBy != "binding.channel" { + t.Errorf("MatchedBy = %q, want 'binding.channel'", route.MatchedBy) + } +} + +func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "general", Default: true}, + {ID: "vip"}, + {ID: "gaming"}, + } + bindings := []config.AgentBinding{ + { + AgentID: "vip", + Match: config.BindingMatch{ + Channel: "discord", + AccountID: "*", + Peer: &config.PeerMatch{Kind: "direct", ID: "user-vip"}, + }, + }, + { + AgentID: "gaming", + Match: config.BindingMatch{ + Channel: "discord", + AccountID: "*", + GuildID: "guild-1", + }, + }, + } + cfg := testConfig(agents, bindings) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "discord", + GuildID: "guild-1", + Peer: &RoutePeer{Kind: "direct", ID: "user-vip"}, + }) + + if route.AgentID != "vip" { + t.Errorf("AgentID = %q, want 'vip' (peer should beat guild)", route.AgentID) + } + if route.MatchedBy != "binding.peer" { + t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy) + } +} + +func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "main", Default: true}, + } + bindings := []config.AgentBinding{ + { + AgentID: "nonexistent", + Match: config.BindingMatch{ + Channel: "telegram", + AccountID: "*", + }, + }, + } + cfg := testConfig(agents, bindings) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "telegram", + }) + + if route.AgentID != "main" { + t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID) + } +} + +func TestResolveRoute_DefaultAgentSelection(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "alpha"}, + {ID: "beta", Default: true}, + {ID: "gamma"}, + } + cfg := testConfig(agents, nil) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "cli", + }) + + if route.AgentID != "beta" { + t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID) + } +} + +func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) { + agents := []config.AgentConfig{ + {ID: "alpha"}, + {ID: "beta"}, + } + cfg := testConfig(agents, nil) + r := NewRouteResolver(cfg) + + route := r.ResolveRoute(RouteInput{ + Channel: "cli", + }) + + if route.AgentID != "alpha" { + t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID) + } +} diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go new file mode 100644 index 000000000..e12f0d1d8 --- /dev/null +++ b/pkg/routing/session_key.go @@ -0,0 +1,183 @@ +package routing + +import ( + "fmt" + "strings" +) + +// DMScope controls DM session isolation granularity. +type DMScope string + +const ( + DMScopeMain DMScope = "main" + DMScopePerPeer DMScope = "per-peer" + DMScopePerChannelPeer DMScope = "per-channel-peer" + DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer" +) + +// RoutePeer represents a chat peer with kind and ID. +type RoutePeer struct { + Kind string // "direct", "group", "channel" + ID string +} + +// SessionKeyParams holds all inputs for session key construction. +type SessionKeyParams struct { + AgentID string + Channel string + AccountID string + Peer *RoutePeer + DMScope DMScope + IdentityLinks map[string][]string +} + +// ParsedSessionKey is the result of parsing an agent-scoped session key. +type ParsedSessionKey struct { + AgentID string + Rest string +} + +// BuildAgentMainSessionKey returns "agent::main". +func BuildAgentMainSessionKey(agentID string) string { + return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey) +} + +// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope. +func BuildAgentPeerSessionKey(params SessionKeyParams) string { + agentID := NormalizeAgentID(params.AgentID) + + peer := params.Peer + if peer == nil { + peer = &RoutePeer{Kind: "direct"} + } + peerKind := strings.TrimSpace(peer.Kind) + if peerKind == "" { + peerKind = "direct" + } + + if peerKind == "direct" { + dmScope := params.DMScope + if dmScope == "" { + dmScope = DMScopeMain + } + peerID := strings.TrimSpace(peer.ID) + + // Resolve identity links (cross-platform collapse) + if dmScope != DMScopeMain && peerID != "" { + if linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != "" { + peerID = linked + } + } + peerID = strings.ToLower(peerID) + + switch dmScope { + case DMScopePerAccountChannelPeer: + if peerID != "" { + channel := normalizeChannel(params.Channel) + accountID := NormalizeAccountID(params.AccountID) + return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID) + } + case DMScopePerChannelPeer: + if peerID != "" { + channel := normalizeChannel(params.Channel) + return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID) + } + case DMScopePerPeer: + if peerID != "" { + return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID) + } + } + return BuildAgentMainSessionKey(agentID) + } + + // Group/channel peers always get per-peer sessions + channel := normalizeChannel(params.Channel) + peerID := strings.ToLower(strings.TrimSpace(peer.ID)) + if peerID == "" { + peerID = "unknown" + } + return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID) +} + +// ParseAgentSessionKey extracts agentId and rest from "agent::". +func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey { + raw := strings.TrimSpace(sessionKey) + if raw == "" { + return nil + } + parts := strings.SplitN(raw, ":", 3) + if len(parts) < 3 { + return nil + } + if parts[0] != "agent" { + return nil + } + agentID := strings.TrimSpace(parts[1]) + rest := parts[2] + if agentID == "" || rest == "" { + return nil + } + return &ParsedSessionKey{AgentID: agentID, Rest: rest} +} + +// IsSubagentSessionKey returns true if the session key represents a subagent. +func IsSubagentSessionKey(sessionKey string) bool { + raw := strings.TrimSpace(sessionKey) + if raw == "" { + return false + } + if strings.HasPrefix(strings.ToLower(raw), "subagent:") { + return true + } + parsed := ParseAgentSessionKey(raw) + if parsed == nil { + return false + } + return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:") +} + +func normalizeChannel(channel string) string { + c := strings.TrimSpace(strings.ToLower(channel)) + if c == "" { + return "unknown" + } + return c +} + +func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string { + if len(identityLinks) == 0 { + return "" + } + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return "" + } + + candidates := make(map[string]bool) + rawCandidate := strings.ToLower(peerID) + if rawCandidate != "" { + candidates[rawCandidate] = true + } + channel = strings.ToLower(strings.TrimSpace(channel)) + if channel != "" { + scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID)) + candidates[scopedCandidate] = true + } + if len(candidates) == 0 { + return "" + } + + for canonical, ids := range identityLinks { + canonicalName := strings.TrimSpace(canonical) + if canonicalName == "" { + continue + } + for _, id := range ids { + normalized := strings.ToLower(strings.TrimSpace(id)) + if normalized != "" && candidates[normalized] { + return canonicalName + } + } + } + return "" +} diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go new file mode 100644 index 000000000..81e4ce018 --- /dev/null +++ b/pkg/routing/session_key_test.go @@ -0,0 +1,162 @@ +package routing + +import "testing" + +func TestBuildAgentMainSessionKey(t *testing.T) { + got := BuildAgentMainSessionKey("sales") + want := "agent:sales:main" + if got != want { + t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want) + } +} + +func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) { + got := BuildAgentMainSessionKey("Sales Bot") + want := "agent:sales-bot:main" + if got != want { + t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want) + } +} + +func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + DMScope: DMScopeMain, + }) + want := "agent:main:main" + if got != want { + t.Errorf("DMScopeMain = %q, want %q", got, want) + } +} + +func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + DMScope: DMScopePerPeer, + }) + want := "agent:main:direct:user123" + if got != want { + t.Errorf("DMScopePerPeer = %q, want %q", got, want) + } +} + +func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + DMScope: DMScopePerChannelPeer, + }) + want := "agent:main:telegram:direct:user123" + if got != want { + t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want) + } +} + +func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + AccountID: "bot1", + Peer: &RoutePeer{Kind: "direct", ID: "User123"}, + DMScope: DMScopePerAccountChannelPeer, + }) + want := "agent:main:telegram:bot1:direct:user123" + if got != want { + t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want) + } +} + +func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: &RoutePeer{Kind: "group", ID: "chat456"}, + DMScope: DMScopePerPeer, + }) + want := "agent:main:telegram:group:chat456" + if got != want { + t.Errorf("GroupPeer = %q, want %q", got, want) + } +} + +func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: nil, + DMScope: DMScopePerPeer, + }) + // nil peer defaults to direct with empty ID, falls to main + want := "agent:main:main" + if got != want { + t.Errorf("NilPeer = %q, want %q", got, want) + } +} + +func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) { + links := map[string][]string{ + "john": {"telegram:user123", "discord:john#1234"}, + } + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + DMScope: DMScopePerPeer, + IdentityLinks: links, + }) + want := "agent:main:direct:john" + if got != want { + t.Errorf("IdentityLink = %q, want %q", got, want) + } +} + +func TestParseAgentSessionKey_Valid(t *testing.T) { + parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123") + if parsed == nil { + t.Fatal("expected non-nil result") + } + if parsed.AgentID != "sales" { + t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID) + } + if parsed.Rest != "telegram:direct:user123" { + t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest) + } +} + +func TestParseAgentSessionKey_Invalid(t *testing.T) { + tests := []string{ + "", + "foo:bar", + "notprefix:sales:main", + "agent::main", + "agent:sales:", + } + for _, input := range tests { + if got := ParseAgentSessionKey(input); got != nil { + t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got) + } + } +} + +func TestIsSubagentSessionKey(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"subagent:task-1", true}, + {"agent:main:subagent:task-1", true}, + {"agent:main:main", false}, + {"agent:main:telegram:direct:user123", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsSubagentSessionKey(tt.input); got != tt.want { + t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} diff --git a/pkg/session/manager.go b/pkg/session/manager.go index b4b825764..12bf33df0 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "sync" "time" @@ -39,22 +40,22 @@ func NewSessionManager(storage string) *SessionManager { } func (sm *SessionManager) GetOrCreate(key string) *Session { - sm.mu.RLock() - session, ok := sm.sessions[key] - sm.mu.RUnlock() + sm.mu.Lock() + defer sm.mu.Unlock() - if !ok { - sm.mu.Lock() - session = &Session{ - Key: key, - Messages: []providers.Message{}, - Created: time.Now(), - Updated: time.Now(), - } - sm.sessions[key] = session - sm.mu.Unlock() + session, ok := sm.sessions[key] + if ok { + return session } + session = &Session{ + Key: key, + Messages: []providers.Message{}, + Created: time.Now(), + Updated: time.Now(), + } + sm.sessions[key] = session + return session } @@ -130,6 +131,12 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { return } + if keepLast <= 0 { + session.Messages = []providers.Message{} + session.Updated = time.Now() + return + } + if len(session.Messages) <= keepLast { return } @@ -138,22 +145,92 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { session.Updated = time.Now() } -func (sm *SessionManager) Save(session *Session) error { +// sanitizeFilename converts a session key into a cross-platform safe filename. +// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the +// volume separator on Windows, so filepath.Base would misinterpret the key. +// We replace it with '_'. The original key is preserved inside the JSON file, +// so loadSessions still maps back to the right in-memory key. +func sanitizeFilename(key string) string { + return strings.ReplaceAll(key, ":", "_") +} + +func (sm *SessionManager) Save(key string) error { if sm.storage == "" { return nil } - sm.mu.Lock() - defer sm.mu.Unlock() + filename := sanitizeFilename(key) - sessionPath := filepath.Join(sm.storage, session.Key+".json") + // filepath.IsLocal rejects empty names, "..", absolute paths, and + // OS-reserved device names (NUL, COM1 … on Windows). + // The extra checks reject "." and any directory separators so that + // the session file is always written directly inside sm.storage. + if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) { + return os.ErrInvalid + } - data, err := json.MarshalIndent(session, "", " ") + // Snapshot under read lock, then perform slow file I/O after unlock. + sm.mu.RLock() + stored, ok := sm.sessions[key] + if !ok { + sm.mu.RUnlock() + return nil + } + + snapshot := Session{ + Key: stored.Key, + Summary: stored.Summary, + Created: stored.Created, + Updated: stored.Updated, + } + if len(stored.Messages) > 0 { + snapshot.Messages = make([]providers.Message, len(stored.Messages)) + copy(snapshot.Messages, stored.Messages) + } else { + snapshot.Messages = []providers.Message{} + } + sm.mu.RUnlock() + + data, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return err } - return os.WriteFile(sessionPath, data, 0644) + sessionPath := filepath.Join(sm.storage, filename+".json") + tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") + if err != nil { + return err + } + + tmpPath := tmpFile.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Chmod(0644); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Sync(); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + + if err := os.Rename(tmpPath, sessionPath); err != nil { + return err + } + cleanup = false + return nil } func (sm *SessionManager) loadSessions() error { @@ -187,3 +264,19 @@ func (sm *SessionManager) loadSessions() error { return nil } + +// SetHistory updates the messages of a session. +func (sm *SessionManager) SetHistory(key string, history []providers.Message) { + sm.mu.Lock() + defer sm.mu.Unlock() + + session, ok := sm.sessions[key] + if ok { + // Create a deep copy to strictly isolate internal state + // from the caller's slice. + msgs := make([]providers.Message, len(history)) + copy(msgs, history) + session.Messages = msgs + session.Updated = time.Now() + } +} diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go new file mode 100644 index 000000000..5ef5f4349 --- /dev/null +++ b/pkg/session/manager_test.go @@ -0,0 +1,74 @@ +package session + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSanitizeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"telegram:123456", "telegram_123456"}, + {"discord:987654321", "discord_987654321"}, + {"slack:C01234", "slack_C01234"}, + {"no-colons-here", "no-colons-here"}, + {"multiple:colons:here", "multiple_colons_here"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := sanitizeFilename(tt.input) + if got != tt.expected { + t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestSave_WithColonInKey(t *testing.T) { + tmpDir := t.TempDir() + sm := NewSessionManager(tmpDir) + + // Create a session with a key containing colon (typical channel session key). + key := "telegram:123456" + sm.GetOrCreate(key) + sm.AddMessage(key, "user", "hello") + + // Save should succeed even though the key contains ':' + if err := sm.Save(key); err != nil { + t.Fatalf("Save(%q) failed: %v", key, err) + } + + // The file on disk should use sanitized name. + expectedFile := filepath.Join(tmpDir, "telegram_123456.json") + if _, err := os.Stat(expectedFile); os.IsNotExist(err) { + t.Fatalf("expected session file %s to exist", expectedFile) + } + + // Load into a fresh manager and verify the session round-trips. + sm2 := NewSessionManager(tmpDir) + history := sm2.GetHistory(key) + if len(history) != 1 { + t.Fatalf("expected 1 message after reload, got %d", len(history)) + } + if history[0].Content != "hello" { + t.Errorf("expected message content %q, got %q", "hello", history[0].Content) + } +} + +func TestSave_RejectsPathTraversal(t *testing.T) { + tmpDir := t.TempDir() + sm := NewSessionManager(tmpDir) + + badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"} + for _, key := range badKeys { + sm.GetOrCreate(key) + if err := sm.Save(key); err == nil { + t.Errorf("Save(%q) should have failed but didn't", key) + } + } +} diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 1f952c1f5..0c63ae067 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -2,13 +2,22 @@ package skills import ( "encoding/json" + "errors" "fmt" + "log/slog" "os" "path/filepath" "regexp" "strings" ) +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) + +const ( + MaxNameLength = 64 + MaxDescriptionLength = 1024 +) + type SkillMetadata struct { Name string `json:"name"` Description string `json:"description"` @@ -21,6 +30,27 @@ type SkillInfo struct { Description string `json:"description"` } +func (info SkillInfo) validate() error { + var errs error + if info.Name == "" { + errs = errors.Join(errs, errors.New("name is required")) + } else { + if len(info.Name) > MaxNameLength { + errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength)) + } + if !namePattern.MatchString(info.Name) { + errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens")) + } + } + + if info.Description == "" { + errs = errors.Join(errs, errors.New("description is required")) + } else if len(info.Description) > MaxDescriptionLength { + errs = errors.Join(errs, fmt.Errorf("description exceeds %d character", MaxDescriptionLength)) + } + return errs +} + type SkillsLoader struct { workspace string workspaceSkills string // workspace skills (项目级别) @@ -54,6 +84,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description + info.Name = metadata.Name + } + if err := info.validate(); err != nil { + slog.Warn("invalid skill from workspace", "name", info.Name, "error", err) + continue } skills = append(skills, info) } @@ -89,6 +124,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description + info.Name = metadata.Name + } + if err := info.validate(); err != nil { + slog.Warn("invalid skill from global", "name", info.Name, "error", err) + continue } skills = append(skills, info) } @@ -123,6 +163,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description + info.Name = metadata.Name + } + if err := info.validate(); err != nil { + slog.Warn("invalid skill from builtin", "name", info.Name, "error", err) + continue } skills = append(skills, info) } diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go new file mode 100644 index 000000000..e0e7109cf --- /dev/null +++ b/pkg/skills/loader_test.go @@ -0,0 +1,77 @@ +package skills + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSkillsInfoValidate(t *testing.T) { + testcases := []struct { + name string + skillName string + description string + wantErr bool + errContains []string + }{ + { + name: "valid-skill", + skillName: "valid-skill", + description: "a valid skill description", + wantErr: false, + }, + { + name: "empty-name", + skillName: "", + description: "description without name", + wantErr: true, + errContains: []string{"name is required"}, + }, + { + name: "empty-description", + skillName: "skill-without-description", + description: "", + wantErr: true, + errContains: []string{"description is required"}, + }, + { + name: "empty-both", + skillName: "", + description: "", + wantErr: true, + errContains: []string{"name is required", "description is required"}, + }, + { + name: "name-with-spaces", + skillName: "skill with spaces", + description: "invalid name with spaces", + wantErr: true, + errContains: []string{"name must be alphanumeric with hyphens"}, + }, + { + name: "name-with-underscore", + skillName: "skill_underscore", + description: "invalid name with underscore", + wantErr: true, + errContains: []string{"name must be alphanumeric with hyphens"}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + info := SkillInfo{ + Name: tc.skillName, + Description: tc.description, + } + err := info.validate() + if tc.wantErr { + assert.Error(t, err) + for _, msg := range tc.errContains { + assert.ErrorContains(t, err, msg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/state/state.go b/pkg/state/state.go new file mode 100644 index 000000000..0bb9cd497 --- /dev/null +++ b/pkg/state/state.go @@ -0,0 +1,172 @@ +package state + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +// State represents the persistent state for a workspace. +// It includes information about the last active channel/chat. +type State struct { + // LastChannel is the last channel used for communication + LastChannel string `json:"last_channel,omitempty"` + + // LastChatID is the last chat ID used for communication + LastChatID string `json:"last_chat_id,omitempty"` + + // Timestamp is the last time this state was updated + Timestamp time.Time `json:"timestamp"` +} + +// Manager manages persistent state with atomic saves. +type Manager struct { + workspace string + state *State + mu sync.RWMutex + stateFile string +} + +// NewManager creates a new state manager for the given workspace. +func NewManager(workspace string) *Manager { + stateDir := filepath.Join(workspace, "state") + stateFile := filepath.Join(stateDir, "state.json") + oldStateFile := filepath.Join(workspace, "state.json") + + // Create state directory if it doesn't exist + os.MkdirAll(stateDir, 0755) + + sm := &Manager{ + workspace: workspace, + stateFile: stateFile, + state: &State{}, + } + + // Try to load from new location first + if _, err := os.Stat(stateFile); os.IsNotExist(err) { + // New file doesn't exist, try migrating from old location + if data, err := os.ReadFile(oldStateFile); err == nil { + if err := json.Unmarshal(data, sm.state); err == nil { + // Migrate to new location + sm.saveAtomic() + log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile) + } + } + } else { + // Load from new location + sm.load() + } + + return sm +} + +// SetLastChannel atomically updates the last channel and saves the state. +// This method uses a temp file + rename pattern for atomic writes, +// ensuring that the state file is never corrupted even if the process crashes. +func (sm *Manager) SetLastChannel(channel string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Update state + sm.state.LastChannel = channel + sm.state.Timestamp = time.Now() + + // Atomic save using temp file + rename + if err := sm.saveAtomic(); err != nil { + return fmt.Errorf("failed to save state atomically: %w", err) + } + + return nil +} + +// SetLastChatID atomically updates the last chat ID and saves the state. +func (sm *Manager) SetLastChatID(chatID string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Update state + sm.state.LastChatID = chatID + sm.state.Timestamp = time.Now() + + // Atomic save using temp file + rename + if err := sm.saveAtomic(); err != nil { + return fmt.Errorf("failed to save state atomically: %w", err) + } + + return nil +} + +// GetLastChannel returns the last channel from the state. +func (sm *Manager) GetLastChannel() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.LastChannel +} + +// GetLastChatID returns the last chat ID from the state. +func (sm *Manager) GetLastChatID() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.LastChatID +} + +// GetTimestamp returns the timestamp of the last state update. +func (sm *Manager) GetTimestamp() time.Time { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.Timestamp +} + +// saveAtomic performs an atomic save using temp file + rename. +// This ensures that the state file is never corrupted: +// 1. Write to a temp file +// 2. Rename temp file to target (atomic on POSIX systems) +// 3. If rename fails, cleanup the temp file +// +// Must be called with the lock held. +func (sm *Manager) saveAtomic() error { + // Create temp file in the same directory as the target + tempFile := sm.stateFile + ".tmp" + + // Marshal state to JSON + data, err := json.MarshalIndent(sm.state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + // Write to temp file + if err := os.WriteFile(tempFile, data, 0644); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Atomic rename from temp to target + if err := os.Rename(tempFile, sm.stateFile); err != nil { + // Cleanup temp file if rename fails + os.Remove(tempFile) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil +} + +// load loads the state from disk. +func (sm *Manager) load() error { + data, err := os.ReadFile(sm.stateFile) + if err != nil { + // File doesn't exist yet, that's OK + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read state file: %w", err) + } + + if err := json.Unmarshal(data, sm.state); err != nil { + return fmt.Errorf("failed to unmarshal state: %w", err) + } + + return nil +} diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go new file mode 100644 index 000000000..ce3dd7215 --- /dev/null +++ b/pkg/state/state_test.go @@ -0,0 +1,216 @@ +package state + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestAtomicSave(t *testing.T) { + // Create temp workspace + tmpDir, err := os.MkdirTemp("", "state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sm := NewManager(tmpDir) + + // Test SetLastChannel + err = sm.SetLastChannel("test-channel") + if err != nil { + t.Fatalf("SetLastChannel failed: %v", err) + } + + // Verify the channel was saved + lastChannel := sm.GetLastChannel() + if lastChannel != "test-channel" { + t.Errorf("Expected channel 'test-channel', got '%s'", lastChannel) + } + + // Verify timestamp was updated + if sm.GetTimestamp().IsZero() { + t.Error("Expected timestamp to be updated") + } + + // Verify state file exists + stateFile := filepath.Join(tmpDir, "state", "state.json") + if _, err := os.Stat(stateFile); os.IsNotExist(err) { + t.Error("Expected state file to exist") + } + + // Create a new manager to verify persistence + sm2 := NewManager(tmpDir) + if sm2.GetLastChannel() != "test-channel" { + t.Errorf("Expected persistent channel 'test-channel', got '%s'", sm2.GetLastChannel()) + } +} + +func TestSetLastChatID(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sm := NewManager(tmpDir) + + // Test SetLastChatID + err = sm.SetLastChatID("test-chat-id") + if err != nil { + t.Fatalf("SetLastChatID failed: %v", err) + } + + // Verify the chat ID was saved + lastChatID := sm.GetLastChatID() + if lastChatID != "test-chat-id" { + t.Errorf("Expected chat ID 'test-chat-id', got '%s'", lastChatID) + } + + // Verify timestamp was updated + if sm.GetTimestamp().IsZero() { + t.Error("Expected timestamp to be updated") + } + + // Create a new manager to verify persistence + sm2 := NewManager(tmpDir) + if sm2.GetLastChatID() != "test-chat-id" { + t.Errorf("Expected persistent chat ID 'test-chat-id', got '%s'", sm2.GetLastChatID()) + } +} + +func TestAtomicity_NoCorruptionOnInterrupt(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sm := NewManager(tmpDir) + + // Write initial state + err = sm.SetLastChannel("initial-channel") + if err != nil { + t.Fatalf("SetLastChannel failed: %v", err) + } + + // Simulate a crash scenario by manually creating a corrupted temp file + tempFile := filepath.Join(tmpDir, "state", "state.json.tmp") + err = os.WriteFile(tempFile, []byte("corrupted data"), 0644) + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + // Verify that the original state is still intact + lastChannel := sm.GetLastChannel() + if lastChannel != "initial-channel" { + t.Errorf("Expected channel 'initial-channel' after corrupted temp file, got '%s'", lastChannel) + } + + // Clean up the temp file manually + os.Remove(tempFile) + + // Now do a proper save + err = sm.SetLastChannel("new-channel") + if err != nil { + t.Fatalf("SetLastChannel failed: %v", err) + } + + // Verify the new state was saved + if sm.GetLastChannel() != "new-channel" { + t.Errorf("Expected channel 'new-channel', got '%s'", sm.GetLastChannel()) + } +} + +func TestConcurrentAccess(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sm := NewManager(tmpDir) + + // Test concurrent writes + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func(idx int) { + channel := fmt.Sprintf("channel-%d", idx) + sm.SetLastChannel(channel) + done <- true + }(i) + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } + + // Verify the final state is consistent + lastChannel := sm.GetLastChannel() + if lastChannel == "" { + t.Error("Expected non-empty channel after concurrent writes") + } + + // Verify state file is valid JSON + stateFile := filepath.Join(tmpDir, "state", "state.json") + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("Failed to read state file: %v", err) + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + t.Errorf("State file contains invalid JSON: %v", err) + } +} + +func TestNewManager_ExistingState(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create initial state + sm1 := NewManager(tmpDir) + sm1.SetLastChannel("existing-channel") + sm1.SetLastChatID("existing-chat-id") + + // Create new manager with same workspace + sm2 := NewManager(tmpDir) + + // Verify state was loaded + if sm2.GetLastChannel() != "existing-channel" { + t.Errorf("Expected channel 'existing-channel', got '%s'", sm2.GetLastChannel()) + } + + if sm2.GetLastChatID() != "existing-chat-id" { + t.Errorf("Expected chat ID 'existing-chat-id', got '%s'", sm2.GetLastChatID()) + } +} + +func TestNewManager_EmptyWorkspace(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sm := NewManager(tmpDir) + + // Verify default state + if sm.GetLastChannel() != "" { + t.Errorf("Expected empty channel, got '%s'", sm.GetLastChannel()) + } + + if sm.GetLastChatID() != "" { + t.Errorf("Expected empty chat ID, got '%s'", sm.GetLastChatID()) + } + + if !sm.GetTimestamp().IsZero() { + t.Error("Expected zero timestamp for new state") + } +} diff --git a/pkg/tools/base.go b/pkg/tools/base.go index 095ac69af..b13174633 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -2,11 +2,12 @@ package tools import "context" +// Tool is the interface that all tools must implement. type Tool interface { Name() string Description() string Parameters() map[string]interface{} - Execute(ctx context.Context, args map[string]interface{}) (string, error) + Execute(ctx context.Context, args map[string]interface{}) *ToolResult } // ContextualTool is an optional interface that tools can implement @@ -16,6 +17,58 @@ type ContextualTool interface { SetContext(channel, chatID string) } +// AsyncCallback is a function type that async tools use to notify completion. +// When an async tool finishes its work, it calls this callback with the result. +// +// The ctx parameter allows the callback to be canceled if the agent is shutting down. +// The result parameter contains the tool's execution result. +// +// Example usage in an async tool: +// +// func (t *MyAsyncTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +// // Start async work in background +// go func() { +// result := doAsyncWork() +// if t.callback != nil { +// t.callback(ctx, result) +// } +// }() +// return AsyncResult("Async task started") +// } +type AsyncCallback func(ctx context.Context, result *ToolResult) + +// AsyncTool is an optional interface that tools can implement to support +// asynchronous execution with completion callbacks. +// +// Async tools return immediately with an AsyncResult, then notify completion +// via the callback set by SetCallback. +// +// This is useful for: +// - Long-running operations that shouldn't block the agent loop +// - Subagent spawns that complete independently +// - Background tasks that need to report results later +// +// Example: +// +// type SpawnTool struct { +// callback AsyncCallback +// } +// +// func (t *SpawnTool) SetCallback(cb AsyncCallback) { +// t.callback = cb +// } +// +// func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { +// go t.runSubagent(ctx, args) +// return AsyncResult("Subagent spawned, will report back") +// } +type AsyncTool interface { + Tool + // SetCallback registers a callback function to be invoked when the async operation completes. + // The callback will be called from a goroutine and should handle thread-safety if needed. + SetCallback(cb AsyncCallback) +} + func ToolToSchema(tool Tool) map[string]interface{} { return map[string]interface{}{ "type": "function", diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 53570a354..e2764d8ac 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -21,17 +22,22 @@ type CronTool struct { cronService *cron.CronService executor JobExecutor msgBus *bus.MessageBus + execTool *ExecTool channel string chatID string mu sync.RWMutex } // NewCronTool creates a new CronTool -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus) *CronTool { +// execTimeout: 0 means no timeout, >0 sets the timeout duration +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool { + execTool := NewExecToolWithConfig(workspace, restrict, config) + execTool.SetTimeout(execTimeout) return &CronTool{ cronService: cronService, executor: executor, msgBus: msgBus, + execTool: execTool, } } @@ -42,7 +48,7 @@ func (t *CronTool) Name() string { // Description returns the tool description func (t *CronTool) Description() string { - return "Schedule reminders and tasks. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am)." + return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly." } // Parameters returns the tool parameters schema @@ -57,7 +63,11 @@ func (t *CronTool) Parameters() map[string]interface{} { }, "message": map[string]interface{}{ "type": "string", - "description": "The reminder/task message to display when triggered (required for add)", + "description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.", + }, + "command": map[string]interface{}{ + "type": "string", + "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.", }, "at_seconds": map[string]interface{}{ "type": "integer", @@ -77,7 +87,7 @@ func (t *CronTool) Parameters() map[string]interface{} { }, "deliver": map[string]interface{}{ "type": "boolean", - "description": "If true, send message directly to channel. If false, let agent process the message (for complex tasks). Default: true", + "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true", }, }, "required": []string{"action"}, @@ -92,11 +102,11 @@ func (t *CronTool) SetContext(channel, chatID string) { t.chatID = chatID } -// Execute runs the tool with given arguments -func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +// Execute runs the tool with the given arguments +func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { action, ok := args["action"].(string) if !ok { - return "", fmt.Errorf("action is required") + return ErrorResult("action is required") } switch action { @@ -111,23 +121,23 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) (st case "disable": return t.enableJob(args, false) default: - return "", fmt.Errorf("unknown action: %s", action) + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) } } -func (t *CronTool) addJob(args map[string]interface{}) (string, error) { +func (t *CronTool) addJob(args map[string]interface{}) *ToolResult { t.mu.RLock() channel := t.channel chatID := t.chatID t.mu.RUnlock() if channel == "" || chatID == "" { - return "Error: no session context (channel/chat_id not set). Use this tool in an active conversation.", nil + return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.") } message, ok := args["message"].(string) if !ok || message == "" { - return "Error: message is required for add", nil + return ErrorResult("message is required for add") } var schedule cron.CronSchedule @@ -156,7 +166,7 @@ func (t *CronTool) addJob(args map[string]interface{}) (string, error) { Expr: cronExpr, } } else { - return "Error: one of at_seconds, every_seconds, or cron_expr is required", nil + return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required") } // Read deliver parameter, default to true @@ -165,6 +175,15 @@ func (t *CronTool) addJob(args map[string]interface{}) (string, error) { deliver = d } + command, _ := args["command"].(string) + if command != "" { + // Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically) + // Actually, let's keep deliver=false to let the system know it's not a simple chat message + // But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set. + // However, logically, it's not "delivered" to chat directly as is. + deliver = false + } + // Truncate message for job name (max 30 chars) messagePreview := utils.Truncate(message, 30) @@ -177,17 +196,23 @@ func (t *CronTool) addJob(args map[string]interface{}) (string, error) { chatID, ) if err != nil { - return fmt.Sprintf("Error adding job: %v", err), nil + return ErrorResult(fmt.Sprintf("Error adding job: %v", err)) } - return fmt.Sprintf("Created job '%s' (id: %s)", job.Name, job.ID), nil + if command != "" { + job.Payload.Command = command + // Need to save the updated payload + t.cronService.UpdateJob(job) + } + + return SilentResult(fmt.Sprintf("Cron job added: %s (id: %s)", job.Name, job.ID)) } -func (t *CronTool) listJobs() (string, error) { +func (t *CronTool) listJobs() *ToolResult { jobs := t.cronService.ListJobs(false) if len(jobs) == 0 { - return "No scheduled jobs.", nil + return SilentResult("No scheduled jobs") } result := "Scheduled jobs:\n" @@ -205,37 +230,37 @@ func (t *CronTool) listJobs() (string, error) { result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo) } - return result, nil + return SilentResult(result) } -func (t *CronTool) removeJob(args map[string]interface{}) (string, error) { +func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult { jobID, ok := args["job_id"].(string) if !ok || jobID == "" { - return "Error: job_id is required for remove", nil + return ErrorResult("job_id is required for remove") } if t.cronService.RemoveJob(jobID) { - return fmt.Sprintf("Removed job %s", jobID), nil + return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID)) } - return fmt.Sprintf("Job %s not found", jobID), nil + return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } -func (t *CronTool) enableJob(args map[string]interface{}, enable bool) (string, error) { +func (t *CronTool) enableJob(args map[string]interface{}, enable bool) *ToolResult { jobID, ok := args["job_id"].(string) if !ok || jobID == "" { - return "Error: job_id is required for enable/disable", nil + return ErrorResult("job_id is required for enable/disable") } job := t.cronService.EnableJob(jobID, enable) if job == nil { - return fmt.Sprintf("Job %s not found", jobID), nil + return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } status := "enabled" if !enable { status = "disabled" } - return fmt.Sprintf("Job '%s' %s", job.Name, status), nil + return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status)) } // ExecuteJob executes a cron job through the agent @@ -252,6 +277,28 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { chatID = "direct" } + // Execute command if present + if job.Payload.Command != "" { + args := map[string]interface{}{ + "command": job.Payload.Command, + } + + result := t.execTool.Execute(ctx, args) + var output string + if result.IsError { + output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM) + } else { + output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM) + } + + t.msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: output, + }) + return "ok" + } + // If deliver=true, send message directly without agent processing if job.Payload.Deliver { t.msgBus.PublishOutbound(bus.OutboundMessage{ @@ -265,7 +312,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // For deliver=false, process through agent (for complex tasks) sessionKey := fmt.Sprintf("cron-%s", job.ID) - // Call agent with the job's message + // Call agent with job's message response, err := t.executor.ProcessDirectWithChannel( ctx, job.Payload.Message, diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 339148e51..1e7c33b45 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -4,20 +4,21 @@ import ( "context" "fmt" "os" - "path/filepath" "strings" ) // EditFileTool edits a file by replacing old_text with new_text. // The old_text must exist exactly in the file. type EditFileTool struct { - allowedDir string // Optional directory restriction for security + allowedDir string + restrict bool } // NewEditFileTool creates a new EditFileTool with optional directory restriction. -func NewEditFileTool(allowedDir string) *EditFileTool { +func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool { return &EditFileTool{ allowedDir: allowedDir, + restrict: restrict, } } @@ -50,78 +51,63 @@ func (t *EditFileTool) Parameters() map[string]interface{} { } } -func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { path, ok := args["path"].(string) if !ok { - return "", fmt.Errorf("path is required") + return ErrorResult("path is required") } oldText, ok := args["old_text"].(string) if !ok { - return "", fmt.Errorf("old_text is required") + return ErrorResult("old_text is required") } newText, ok := args["new_text"].(string) if !ok { - return "", fmt.Errorf("new_text is required") + return ErrorResult("new_text is required") } - // Resolve path and enforce directory restriction if configured - resolvedPath := path - if filepath.IsAbs(path) { - resolvedPath = filepath.Clean(path) - } else { - abs, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("failed to resolve path: %w", err) - } - resolvedPath = abs - } - - // Check directory restriction - if t.allowedDir != "" { - allowedAbs, err := filepath.Abs(t.allowedDir) - if err != nil { - return "", fmt.Errorf("failed to resolve allowed directory: %w", err) - } - if !strings.HasPrefix(resolvedPath, allowedAbs) { - return "", fmt.Errorf("path %s is outside allowed directory %s", path, t.allowedDir) - } + resolvedPath, err := validatePath(path, t.allowedDir, t.restrict) + if err != nil { + return ErrorResult(err.Error()) } if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { - return "", fmt.Errorf("file not found: %s", path) + return ErrorResult(fmt.Sprintf("file not found: %s", path)) } content, err := os.ReadFile(resolvedPath) if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) } contentStr := string(content) if !strings.Contains(contentStr, oldText) { - return "", fmt.Errorf("old_text not found in file. Make sure it matches exactly") + return ErrorResult("old_text not found in file. Make sure it matches exactly") } count := strings.Count(contentStr, oldText) if count > 1 { - return "", fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) + return ErrorResult(fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count)) } newContent := strings.Replace(contentStr, oldText, newText, 1) if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) } - return fmt.Sprintf("Successfully edited %s", path), nil + return SilentResult(fmt.Sprintf("File edited: %s", path)) } -type AppendFileTool struct{} +type AppendFileTool struct { + workspace string + restrict bool +} -func NewAppendFileTool() *AppendFileTool { - return &AppendFileTool{} +func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool { + return &AppendFileTool{workspace: workspace, restrict: restrict} } func (t *AppendFileTool) Name() string { @@ -149,28 +135,31 @@ func (t *AppendFileTool) Parameters() map[string]interface{} { } } -func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { path, ok := args["path"].(string) if !ok { - return "", fmt.Errorf("path is required") + return ErrorResult("path is required") } content, ok := args["content"].(string) if !ok { - return "", fmt.Errorf("content is required") + return ErrorResult("content is required") } - filePath := filepath.Clean(path) - - f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) if err != nil { - return "", fmt.Errorf("failed to open file: %w", err) + return ErrorResult(err.Error()) + } + + f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open file: %v", err)) } defer f.Close() if _, err := f.WriteString(content); err != nil { - return "", fmt.Errorf("failed to append to file: %w", err) + return ErrorResult(fmt.Sprintf("failed to append to file: %v", err)) } - return fmt.Sprintf("Successfully appended to %s", path), nil + return SilentResult(fmt.Sprintf("Appended to %s", path)) } diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go new file mode 100644 index 000000000..c4c02772d --- /dev/null +++ b/pkg/tools/edit_test.go @@ -0,0 +1,289 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEditTool_EditFile_Success verifies successful file editing +func TestEditTool_EditFile_Success(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0644) + + tool := NewEditFileTool(tmpDir, true) + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "old_text": "World", + "new_text": "Universe", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // Should return SilentResult + if !result.Silent { + t.Errorf("Expected Silent=true for EditFile, got false") + } + + // ForUser should be empty (silent result) + if result.ForUser != "" { + t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) + } + + // Verify file was actually edited + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read edited file: %v", err) + } + contentStr := string(content) + if !strings.Contains(contentStr, "Hello Universe") { + t.Errorf("Expected file to contain 'Hello Universe', got: %s", contentStr) + } + if strings.Contains(contentStr, "Hello World") { + t.Errorf("Expected 'Hello World' to be replaced, got: %s", contentStr) + } +} + +// TestEditTool_EditFile_NotFound verifies error handling for non-existent file +func TestEditTool_EditFile_NotFound(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "nonexistent.txt") + + tool := NewEditFileTool(tmpDir, true) + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "old_text": "old", + "new_text": "new", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for non-existent file") + } + + // Should mention file not found + if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { + t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist +func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("Hello World"), 0644) + + tool := NewEditFileTool(tmpDir, true) + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "old_text": "Goodbye", + "new_text": "Hello", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when old_text not found") + } + + // Should mention old_text not found + if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { + t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times +func TestEditTool_EditFile_MultipleMatches(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("test test test"), 0644) + + tool := NewEditFileTool(tmpDir, true) + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "old_text": "test", + "new_text": "done", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when old_text appears multiple times") + } + + // Should mention multiple occurrences + if !strings.Contains(result.ForLLM, "times") && !strings.Contains(result.ForUser, "times") { + t.Errorf("Expected 'multiple times' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory +func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { + tmpDir := t.TempDir() + otherDir := t.TempDir() + testFile := filepath.Join(otherDir, "test.txt") + os.WriteFile(testFile, []byte("content"), 0644) + + tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "old_text": "content", + "new_text": "new", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when path is outside allowed directory") + } + + // Should mention outside allowed directory + if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") { + t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestEditTool_EditFile_MissingPath verifies error handling for missing path +func TestEditTool_EditFile_MissingPath(t *testing.T) { + tool := NewEditFileTool("", false) + ctx := context.Background() + args := map[string]interface{}{ + "old_text": "old", + "new_text": "new", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when path is missing") + } +} + +// TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text +func TestEditTool_EditFile_MissingOldText(t *testing.T) { + tool := NewEditFileTool("", false) + ctx := context.Background() + args := map[string]interface{}{ + "path": "/tmp/test.txt", + "new_text": "new", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when old_text is missing") + } +} + +// TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text +func TestEditTool_EditFile_MissingNewText(t *testing.T) { + tool := NewEditFileTool("", false) + ctx := context.Background() + args := map[string]interface{}{ + "path": "/tmp/test.txt", + "old_text": "old", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when new_text is missing") + } +} + +// TestEditTool_AppendFile_Success verifies successful file appending +func TestEditTool_AppendFile_Success(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("Initial content"), 0644) + + tool := NewAppendFileTool("", false) + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "content": "\nAppended content", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // Should return SilentResult + if !result.Silent { + t.Errorf("Expected Silent=true for AppendFile, got false") + } + + // ForUser should be empty (silent result) + if result.ForUser != "" { + t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) + } + + // Verify content was actually appended + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + contentStr := string(content) + if !strings.Contains(contentStr, "Initial content") { + t.Errorf("Expected original content to remain, got: %s", contentStr) + } + if !strings.Contains(contentStr, "Appended content") { + t.Errorf("Expected appended content, got: %s", contentStr) + } +} + +// TestEditTool_AppendFile_MissingPath verifies error handling for missing path +func TestEditTool_AppendFile_MissingPath(t *testing.T) { + tool := NewAppendFileTool("", false) + ctx := context.Background() + args := map[string]interface{}{ + "content": "test", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when path is missing") + } +} + +// TestEditTool_AppendFile_MissingContent verifies error handling for missing content +func TestEditTool_AppendFile_MissingContent(t *testing.T) { + tool := NewAppendFileTool("", false) + ctx := context.Background() + args := map[string]interface{}{ + "path": "/tmp/test.txt", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when content is missing") + } +} diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 721eb7f94..09063ea0a 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -5,9 +5,86 @@ import ( "fmt" "os" "path/filepath" + "strings" ) -type ReadFileTool struct{} +// validatePath ensures the given path is within the workspace if restrict is true. +func validatePath(path, workspace string, restrict bool) (string, error) { + if workspace == "" { + return path, nil + } + + absWorkspace, err := filepath.Abs(workspace) + if err != nil { + return "", fmt.Errorf("failed to resolve workspace path: %w", err) + } + + var absPath string + if filepath.IsAbs(path) { + absPath = filepath.Clean(path) + } else { + absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) + if err != nil { + return "", fmt.Errorf("failed to resolve file path: %w", err) + } + } + + if restrict { + if !isWithinWorkspace(absPath, absWorkspace) { + return "", fmt.Errorf("access denied: path is outside the workspace") + } + + workspaceReal := absWorkspace + if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil { + workspaceReal = resolved + } + + if resolved, err := filepath.EvalSymlinks(absPath); err == nil { + if !isWithinWorkspace(resolved, workspaceReal) { + return "", fmt.Errorf("access denied: symlink resolves outside workspace") + } + } else if os.IsNotExist(err) { + if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil { + if !isWithinWorkspace(parentResolved, workspaceReal) { + return "", fmt.Errorf("access denied: symlink resolves outside workspace") + } + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + } else { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + } + + return absPath, nil +} + +func resolveExistingAncestor(path string) (string, error) { + for current := filepath.Clean(path); ; current = filepath.Dir(current) { + if resolved, err := filepath.EvalSymlinks(current); err == nil { + return resolved, nil + } else if !os.IsNotExist(err) { + return "", err + } + if filepath.Dir(current) == current { + return "", os.ErrNotExist + } + } +} + +func isWithinWorkspace(candidate, workspace string) bool { + rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +type ReadFileTool struct { + workspace string + restrict bool +} + +func NewReadFileTool(workspace string, restrict bool) *ReadFileTool { + return &ReadFileTool{workspace: workspace, restrict: restrict} +} func (t *ReadFileTool) Name() string { return "read_file" @@ -30,21 +107,33 @@ func (t *ReadFileTool) Parameters() map[string]interface{} { } } -func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { path, ok := args["path"].(string) if !ok { - return "", fmt.Errorf("path is required") + return ErrorResult("path is required") } - content, err := os.ReadFile(path) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) if err != nil { - return "", fmt.Errorf("failed to read file: %w", err) + return ErrorResult(err.Error()) } - return string(content), nil + content, err := os.ReadFile(resolvedPath) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) + } + + return NewToolResult(string(content)) } -type WriteFileTool struct{} +type WriteFileTool struct { + workspace string + restrict bool +} + +func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool { + return &WriteFileTool{workspace: workspace, restrict: restrict} +} func (t *WriteFileTool) Name() string { return "write_file" @@ -71,30 +160,42 @@ func (t *WriteFileTool) Parameters() map[string]interface{} { } } -func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { path, ok := args["path"].(string) if !ok { - return "", fmt.Errorf("path is required") + return ErrorResult("path is required") } content, ok := args["content"].(string) if !ok { - return "", fmt.Errorf("content is required") + return ErrorResult("content is required") } - dir := filepath.Dir(path) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) + if err != nil { + return ErrorResult(err.Error()) + } + + dir := filepath.Dir(resolvedPath) if err := os.MkdirAll(dir, 0755); err != nil { - return "", fmt.Errorf("failed to create directory: %w", err) + return ErrorResult(fmt.Sprintf("failed to create directory: %v", err)) } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - return "", fmt.Errorf("failed to write file: %w", err) + if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil { + return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) } - return "File written successfully", nil + return SilentResult(fmt.Sprintf("File written: %s", path)) } -type ListDirTool struct{} +type ListDirTool struct { + workspace string + restrict bool +} + +func NewListDirTool(workspace string, restrict bool) *ListDirTool { + return &ListDirTool{workspace: workspace, restrict: restrict} +} func (t *ListDirTool) Name() string { return "list_dir" @@ -117,15 +218,20 @@ func (t *ListDirTool) Parameters() map[string]interface{} { } } -func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { path, ok := args["path"].(string) if !ok { path = "." } - entries, err := os.ReadDir(path) + resolvedPath, err := validatePath(path, t.workspace, t.restrict) if err != nil { - return "", fmt.Errorf("failed to read directory: %w", err) + return ErrorResult(err.Error()) + } + + entries, err := os.ReadDir(resolvedPath) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) } result := "" @@ -137,5 +243,5 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) } } - return result, nil + return NewToolResult(result) } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go new file mode 100644 index 000000000..958036419 --- /dev/null +++ b/pkg/tools/filesystem_test.go @@ -0,0 +1,281 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestFilesystemTool_ReadFile_Success verifies successful file reading +func TestFilesystemTool_ReadFile_Success(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("test content"), 0644) + + tool := &ReadFileTool{} + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForLLM should contain file content + if !strings.Contains(result.ForLLM, "test content") { + t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM) + } + + // ReadFile returns NewToolResult which only sets ForLLM, not ForUser + // This is the expected behavior - file content goes to LLM, not directly to user + if result.ForUser != "" { + t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser) + } +} + +// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file +func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { + tool := &ReadFileTool{} + ctx := context.Background() + args := map[string]interface{}{ + "path": "/nonexistent_file_12345.txt", + } + + result := tool.Execute(ctx, args) + + // Failure should be marked as error + if !result.IsError { + t.Errorf("Expected error for missing file, got IsError=false") + } + + // Should contain error message + if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { + t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + } +} + +// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path +func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { + tool := &ReadFileTool{} + ctx := context.Background() + args := map[string]interface{}{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when path is missing") + } + + // Should mention required parameter + if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { + t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestFilesystemTool_WriteFile_Success verifies successful file writing +func TestFilesystemTool_WriteFile_Success(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "newfile.txt") + + tool := &WriteFileTool{} + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "content": "hello world", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // WriteFile returns SilentResult + if !result.Silent { + t.Errorf("Expected Silent=true for WriteFile, got false") + } + + // ForUser should be empty (silent result) + if result.ForUser != "" { + t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) + } + + // Verify file was actually written + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(content) != "hello world" { + t.Errorf("Expected file content 'hello world', got: %s", string(content)) + } +} + +// TestFilesystemTool_WriteFile_CreateDir verifies directory creation +func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") + + tool := &WriteFileTool{} + ctx := context.Background() + args := map[string]interface{}{ + "path": testFile, + "content": "test", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM) + } + + // Verify directory was created and file written + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("Failed to read written file: %v", err) + } + if string(content) != "test" { + t.Errorf("Expected file content 'test', got: %s", string(content)) + } +} + +// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path +func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { + tool := &WriteFileTool{} + ctx := context.Background() + args := map[string]interface{}{ + "content": "test", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when path is missing") + } +} + +// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content +func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { + tool := &WriteFileTool{} + ctx := context.Background() + args := map[string]interface{}{ + "path": "/tmp/test.txt", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when content is missing") + } + + // Should mention required parameter + if !strings.Contains(result.ForLLM, "content is required") && !strings.Contains(result.ForUser, "content is required") { + t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestFilesystemTool_ListDir_Success verifies successful directory listing +func TestFilesystemTool_ListDir_Success(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0644) + os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0644) + os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755) + + tool := &ListDirTool{} + ctx := context.Background() + args := map[string]interface{}{ + "path": tmpDir, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // Should list files and directories + if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { + t.Errorf("Expected files in listing, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "subdir") { + t.Errorf("Expected subdir in listing, got: %s", result.ForLLM) + } +} + +// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory +func TestFilesystemTool_ListDir_NotFound(t *testing.T) { + tool := &ListDirTool{} + ctx := context.Background() + args := map[string]interface{}{ + "path": "/nonexistent_directory_12345", + } + + result := tool.Execute(ctx, args) + + // Failure should be marked as error + if !result.IsError { + t.Errorf("Expected error for non-existent directory, got IsError=false") + } + + // Should contain error message + if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { + t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + } +} + +// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory +func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { + tool := &ListDirTool{} + ctx := context.Background() + args := map[string]interface{}{} + + result := tool.Execute(ctx, args) + + // Should use "." as default path + if result.IsError { + t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM) + } +} + +// Block paths that look inside workspace but point outside via symlink. +func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { + + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + if err := os.MkdirAll(workspace, 0755); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + + secret := filepath.Join(root, "secret.txt") + if err := os.WriteFile(secret, []byte("top secret"), 0644); err != nil { + t.Fatalf("failed to write secret file: %v", err) + } + + link := filepath.Join(workspace, "leak.txt") + if err := os.Symlink(secret, link); err != nil { + t.Skipf("symlink not supported in this environment: %v", err) + } + + tool := NewReadFileTool(workspace, true) + result := tool.Execute(context.Background(), map[string]interface{}{ + "path": link, + }) + + if !result.IsError { + t.Fatalf("expected symlink escape to be blocked") + } + if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") { + t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go new file mode 100644 index 000000000..abca5ec1e --- /dev/null +++ b/pkg/tools/i2c.go @@ -0,0 +1,147 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "runtime" +) + +// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. +type I2CTool struct{} + +func NewI2CTool() *I2CTool { + return &I2CTool{} +} + +func (t *I2CTool) Name() string { + return "i2c" +} + +func (t *I2CTool) Description() string { + return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only." +} + +func (t *I2CTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "enum": []string{"detect", "scan", "read", "write"}, + "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", + }, + "bus": map[string]interface{}{ + "type": "string", + "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", + }, + "address": map[string]interface{}{ + "type": "integer", + "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", + }, + "register": map[string]interface{}{ + "type": "integer", + "description": "Register address to read from or write to. If set, sends register byte before read/write.", + }, + "data": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "integer"}, + "description": "Bytes to write (0-255 each). Required for write action.", + }, + "length": map[string]interface{}{ + "type": "integer", + "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", + }, + "confirm": map[string]interface{}{ + "type": "boolean", + "description": "Must be true for write operations. Safety guard to prevent accidental writes.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if runtime.GOOS != "linux" { + return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.") + } + + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "detect": + return t.detect() + case "scan": + return t.scan(args) + case "read": + return t.readDevice(args) + case "write": + return t.writeDevice(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) + } +} + +// detect lists available I2C buses by globbing /dev/i2c-* +func (t *I2CTool) detect() *ToolResult { + matches, err := filepath.Glob("/dev/i2c-*") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err)) + } + + if len(matches) == 0 { + return SilentResult("No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)") + } + + type busInfo struct { + Path string `json:"path"` + Bus string `json:"bus"` + } + + buses := make([]busInfo, 0, len(matches)) + re := regexp.MustCompile(`/dev/i2c-(\d+)`) + for _, m := range matches { + if sub := re.FindStringSubmatch(m); sub != nil { + buses = append(buses, busInfo{Path: m, Bus: sub[1]}) + } + } + + result, _ := json.MarshalIndent(buses, "", " ") + return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) +} + +// isValidBusID checks that a bus identifier is a simple number (prevents path injection) +func isValidBusID(id string) bool { + matched, _ := regexp.MatchString(`^\d+$`, id) + return matched +} + +// parseI2CAddress extracts and validates an I2C address from args +func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) { + addrFloat, ok := args["address"].(float64) + if !ok { + return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") + } + addr := int(addrFloat) + if addr < 0x03 || addr > 0x77 { + return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)") + } + return addr, nil +} + +// parseI2CBus extracts and validates an I2C bus from args +func parseI2CBus(args map[string]interface{}) (string, *ToolResult) { + bus, ok := args["bus"].(string) + if !ok || bus == "" { + return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") + } + if !isValidBusID(bus) { + return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")") + } + return bus, nil +} diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go new file mode 100644 index 000000000..294f7ecbc --- /dev/null +++ b/pkg/tools/i2c_linux.go @@ -0,0 +1,282 @@ +package tools + +import ( + "encoding/json" + "fmt" + "syscall" + "unsafe" +) + +// I2C ioctl constants from Linux kernel headers (, ) +const ( + i2cSlave = 0x0703 // Set slave address (fails if in use by driver) + i2cFuncs = 0x0705 // Query adapter functionality bitmask + i2cSmbus = 0x0720 // Perform SMBus transaction + + // I2C_FUNC capability bits + i2cFuncSmbusQuick = 0x00010000 + i2cFuncSmbusReadByte = 0x00020000 + + // SMBus transaction types + i2cSmbusRead = 0 + i2cSmbusWrite = 1 + + // SMBus protocol sizes + i2cSmbusQuick = 0 + i2cSmbusByte = 1 +) + +// i2cSmbusData matches the kernel union i2c_smbus_data (34 bytes max). +// For quick and byte transactions only the first byte is used (if at all). +type i2cSmbusData [34]byte + +// i2cSmbusArgs matches the kernel struct i2c_smbus_ioctl_data. +type i2cSmbusArgs struct { + readWrite uint8 + command uint8 + size uint32 + data *i2cSmbusData +} + +// smbusProbe performs a single SMBus probe at the given address. +// Uses SMBus Quick Write (safest) or falls back to SMBus Read Byte for +// EEPROM address ranges where quick write can corrupt AT24RF08 chips. +// This matches i2cdetect's MODE_AUTO behavior. +func smbusProbe(fd int, addr int, hasQuick bool) bool { + // EEPROM ranges: use read byte (quick write can corrupt AT24RF08) + useReadByte := (addr >= 0x30 && addr <= 0x37) || (addr >= 0x50 && addr <= 0x5F) + + if !useReadByte && hasQuick { + // SMBus Quick Write: [START] [ADDR|W] [ACK/NACK] [STOP] + // Safest probe — no data transferred + args := i2cSmbusArgs{ + readWrite: i2cSmbusWrite, + command: 0, + size: i2cSmbusQuick, + data: nil, + } + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + return errno == 0 + } + + // SMBus Read Byte: [START] [ADDR|R] [ACK/NACK] [DATA] [STOP] + var data i2cSmbusData + args := i2cSmbusArgs{ + readWrite: i2cSmbusRead, + command: 0, + size: i2cSmbusByte, + data: &data, + } + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + return errno == 0 +} + +// scan probes valid 7-bit addresses on a bus for connected devices. +// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO: +// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges. +func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err)) + } + defer syscall.Close(fd) + + // Query adapter capabilities to determine available probe methods. + // I2C_FUNCS writes an unsigned long, which is word-sized on Linux. + var funcs uintptr + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs))) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno)) + } + + hasQuick := funcs&i2cFuncSmbusQuick != 0 + hasReadByte := funcs&i2cFuncSmbusReadByte != 0 + + if !hasQuick && !hasReadByte { + return ErrorResult(fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath)) + } + + type deviceEntry struct { + Address string `json:"address"` + Status string `json:"status,omitempty"` + } + + var found []deviceEntry + // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07 + for addr := 0x08; addr <= 0x77; addr++ { + // Set slave address — EBUSY means a kernel driver owns this address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + if errno == syscall.EBUSY { + found = append(found, deviceEntry{ + Address: fmt.Sprintf("0x%02x", addr), + Status: "busy (in use by kernel driver)", + }) + } + continue + } + + if smbusProbe(fd, addr, hasQuick) { + found = append(found, deviceEntry{ + Address: fmt.Sprintf("0x%02x", addr), + }) + } + } + + if len(found) == 0 { + return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "bus": devPath, + "devices": found, + "count": len(found), + }, "", " ") + return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) +} + +// readDevice reads bytes from an I2C device, optionally at a specific register +func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + addr, errResult := parseI2CAddress(args) + if errResult != nil { + return errResult + } + + length := 1 + if l, ok := args["length"].(float64); ok { + length = int(l) + } + if length < 1 || length > 256 { + return ErrorResult("length must be between 1 and 256") + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) + } + defer syscall.Close(fd) + + // Set slave address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) + } + + // If register is specified, write it first + if regFloat, ok := args["register"].(float64); ok { + reg := int(regFloat) + if reg < 0 || reg > 255 { + return ErrorResult("register must be between 0x00 and 0xFF") + } + _, err := syscall.Write(fd, []byte{byte(reg)}) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err)) + } + } + + // Read data + buf := make([]byte, length) + n, err := syscall.Read(fd, buf) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err)) + } + + // Format as hex bytes + hexBytes := make([]string, n) + intBytes := make([]int, n) + for i := 0; i < n; i++ { + hexBytes[i] = fmt.Sprintf("0x%02x", buf[i]) + intBytes[i] = int(buf[i]) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "bus": devPath, + "address": fmt.Sprintf("0x%02x", addr), + "bytes": intBytes, + "hex": hexBytes, + "length": n, + }, "", " ") + return SilentResult(string(result)) +} + +// writeDevice writes bytes to an I2C device, optionally at a specific register +func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult { + confirm, _ := args["confirm"].(bool) + if !confirm { + return ErrorResult("write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.") + } + + bus, errResult := parseI2CBus(args) + if errResult != nil { + return errResult + } + + addr, errResult := parseI2CAddress(args) + if errResult != nil { + return errResult + } + + dataRaw, ok := args["data"].([]interface{}) + if !ok || len(dataRaw) == 0 { + return ErrorResult("data is required for write (array of byte values 0-255)") + } + if len(dataRaw) > 256 { + return ErrorResult("data too long: maximum 256 bytes per I2C transaction") + } + + data := make([]byte, 0, len(dataRaw)+1) + + // If register is specified, prepend it to the data + if regFloat, ok := args["register"].(float64); ok { + reg := int(regFloat) + if reg < 0 || reg > 255 { + return ErrorResult("register must be between 0x00 and 0xFF") + } + data = append(data, byte(reg)) + } + + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + data = append(data, byte(b)) + } + + devPath := fmt.Sprintf("/dev/i2c-%s", bus) + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err)) + } + defer syscall.Close(fd) + + // Set slave address + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr)) + if errno != 0 { + return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno)) + } + + // Write data + n, err := syscall.Write(fd, data) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err)) + } + + return SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath)) +} diff --git a/pkg/tools/i2c_other.go b/pkg/tools/i2c_other.go new file mode 100644 index 000000000..d1d581348 --- /dev/null +++ b/pkg/tools/i2c_other.go @@ -0,0 +1,18 @@ +//go:build !linux + +package tools + +// scan is a stub for non-Linux platforms. +func (t *I2CTool) scan(args map[string]interface{}) *ToolResult { + return ErrorResult("I2C is only supported on Linux") +} + +// readDevice is a stub for non-Linux platforms. +func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult { + return ErrorResult("I2C is only supported on Linux") +} + +// writeDevice is a stub for non-Linux platforms. +func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult { + return ErrorResult("I2C is only supported on Linux") +} diff --git a/pkg/tools/message.go b/pkg/tools/message.go index e090234d3..abedb1316 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -11,6 +11,7 @@ type MessageTool struct { sendCallback SendCallback defaultChannel string defaultChatID string + sentInRound bool // Tracks whether a message was sent in the current processing round } func NewMessageTool() *MessageTool { @@ -49,16 +50,22 @@ func (t *MessageTool) Parameters() map[string]interface{} { func (t *MessageTool) SetContext(channel, chatID string) { t.defaultChannel = channel t.defaultChatID = chatID + t.sentInRound = false // Reset send tracking for new processing round +} + +// HasSentInRound returns true if the message tool sent a message during the current round. +func (t *MessageTool) HasSentInRound() bool { + return t.sentInRound } func (t *MessageTool) SetSendCallback(callback SendCallback) { t.sendCallback = callback } -func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { content, ok := args["content"].(string) if !ok { - return "", fmt.Errorf("content is required") + return &ToolResult{ForLLM: "content is required", IsError: true} } channel, _ := args["channel"].(string) @@ -72,16 +79,25 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) } if channel == "" || chatID == "" { - return "Error: No target channel/chat specified", nil + return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true} } if t.sendCallback == nil { - return "Error: Message sending not configured", nil + return &ToolResult{ForLLM: "Message sending not configured", IsError: true} } if err := t.sendCallback(channel, chatID, content); err != nil { - return fmt.Sprintf("Error sending message: %v", err), nil + return &ToolResult{ + ForLLM: fmt.Sprintf("sending message: %v", err), + IsError: true, + Err: err, + } } - return fmt.Sprintf("Message sent to %s:%s", channel, chatID), nil + t.sentInRound = true + // Silent: user already received the message directly + return &ToolResult{ + ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), + Silent: true, + } } diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go new file mode 100644 index 000000000..4bedbe79b --- /dev/null +++ b/pkg/tools/message_test.go @@ -0,0 +1,259 @@ +package tools + +import ( + "context" + "errors" + "testing" +) + +func TestMessageTool_Execute_Success(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("test-channel", "test-chat-id") + + var sentChannel, sentChatID, sentContent string + tool.SetSendCallback(func(channel, chatID, content string) error { + sentChannel = channel + sentChatID = chatID + sentContent = content + return nil + }) + + ctx := context.Background() + args := map[string]interface{}{ + "content": "Hello, world!", + } + + result := tool.Execute(ctx, args) + + // Verify message was sent with correct parameters + if sentChannel != "test-channel" { + t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel) + } + if sentChatID != "test-chat-id" { + t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID) + } + if sentContent != "Hello, world!" { + t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent) + } + + // Verify ToolResult meets US-011 criteria: + // - Send success returns SilentResult (Silent=true) + if !result.Silent { + t.Error("Expected Silent=true for successful send") + } + + // - ForLLM contains send status description + if result.ForLLM != "Message sent to test-channel:test-chat-id" { + t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM) + } + + // - ForUser is empty (user already received message directly) + if result.ForUser != "" { + t.Errorf("Expected ForUser to be empty, got '%s'", result.ForUser) + } + + // - IsError should be false + if result.IsError { + t.Error("Expected IsError=false for successful send") + } +} + +func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("default-channel", "default-chat-id") + + var sentChannel, sentChatID string + tool.SetSendCallback(func(channel, chatID, content string) error { + sentChannel = channel + sentChatID = chatID + return nil + }) + + ctx := context.Background() + args := map[string]interface{}{ + "content": "Test message", + "channel": "custom-channel", + "chat_id": "custom-chat-id", + } + + result := tool.Execute(ctx, args) + + // Verify custom channel/chatID were used instead of defaults + if sentChannel != "custom-channel" { + t.Errorf("Expected channel 'custom-channel', got '%s'", sentChannel) + } + if sentChatID != "custom-chat-id" { + t.Errorf("Expected chatID 'custom-chat-id', got '%s'", sentChatID) + } + + if !result.Silent { + t.Error("Expected Silent=true") + } + if result.ForLLM != "Message sent to custom-channel:custom-chat-id" { + t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM) + } +} + +func TestMessageTool_Execute_SendFailure(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("test-channel", "test-chat-id") + + sendErr := errors.New("network error") + tool.SetSendCallback(func(channel, chatID, content string) error { + return sendErr + }) + + ctx := context.Background() + args := map[string]interface{}{ + "content": "Test message", + } + + result := tool.Execute(ctx, args) + + // Verify ToolResult for send failure: + // - Send failure returns ErrorResult (IsError=true) + if !result.IsError { + t.Error("Expected IsError=true for failed send") + } + + // - ForLLM contains error description + expectedErrMsg := "sending message: network error" + if result.ForLLM != expectedErrMsg { + t.Errorf("Expected ForLLM '%s', got '%s'", expectedErrMsg, result.ForLLM) + } + + // - Err field should contain original error + if result.Err == nil { + t.Error("Expected Err to be set") + } + if result.Err != sendErr { + t.Errorf("Expected Err to be sendErr, got %v", result.Err) + } +} + +func TestMessageTool_Execute_MissingContent(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("test-channel", "test-chat-id") + + ctx := context.Background() + args := map[string]interface{}{} // content missing + + result := tool.Execute(ctx, args) + + // Verify error result for missing content + if !result.IsError { + t.Error("Expected IsError=true for missing content") + } + if result.ForLLM != "content is required" { + t.Errorf("Expected ForLLM 'content is required', got '%s'", result.ForLLM) + } +} + +func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { + tool := NewMessageTool() + // No SetContext called, so defaultChannel and defaultChatID are empty + + tool.SetSendCallback(func(channel, chatID, content string) error { + return nil + }) + + ctx := context.Background() + args := map[string]interface{}{ + "content": "Test message", + } + + result := tool.Execute(ctx, args) + + // Verify error when no target channel specified + if !result.IsError { + t.Error("Expected IsError=true when no target channel") + } + if result.ForLLM != "No target channel/chat specified" { + t.Errorf("Expected ForLLM 'No target channel/chat specified', got '%s'", result.ForLLM) + } +} + +func TestMessageTool_Execute_NotConfigured(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("test-channel", "test-chat-id") + // No SetSendCallback called + + ctx := context.Background() + args := map[string]interface{}{ + "content": "Test message", + } + + result := tool.Execute(ctx, args) + + // Verify error when send callback not configured + if !result.IsError { + t.Error("Expected IsError=true when send callback not configured") + } + if result.ForLLM != "Message sending not configured" { + t.Errorf("Expected ForLLM 'Message sending not configured', got '%s'", result.ForLLM) + } +} + +func TestMessageTool_Name(t *testing.T) { + tool := NewMessageTool() + if tool.Name() != "message" { + t.Errorf("Expected name 'message', got '%s'", tool.Name()) + } +} + +func TestMessageTool_Description(t *testing.T) { + tool := NewMessageTool() + desc := tool.Description() + if desc == "" { + t.Error("Description should not be empty") + } +} + +func TestMessageTool_Parameters(t *testing.T) { + tool := NewMessageTool() + params := tool.Parameters() + + // Verify parameters structure + typ, ok := params["type"].(string) + if !ok || typ != "object" { + t.Error("Expected type 'object'") + } + + props, ok := params["properties"].(map[string]interface{}) + if !ok { + t.Fatal("Expected properties to be a map") + } + + // Check required properties + required, ok := params["required"].([]string) + if !ok || len(required) != 1 || required[0] != "content" { + t.Error("Expected 'content' to be required") + } + + // Check content property + contentProp, ok := props["content"].(map[string]interface{}) + if !ok { + t.Error("Expected 'content' property") + } + if contentProp["type"] != "string" { + t.Error("Expected content type to be 'string'") + } + + // Check channel property (optional) + channelProp, ok := props["channel"].(map[string]interface{}) + if !ok { + t.Error("Expected 'channel' property") + } + if channelProp["type"] != "string" { + t.Error("Expected channel type to be 'string'") + } + + // Check chat_id property (optional) + chatIDProp, ok := props["chat_id"].(map[string]interface{}) + if !ok { + t.Error("Expected 'chat_id' property") + } + if chatIDProp["type"] != "string" { + t.Error("Expected chat_id type to be 'string'") + } +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index a769664de..c8cf92863 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" ) type ToolRegistry struct { @@ -33,11 +34,14 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) { return tool, ok } -func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) { - return r.ExecuteWithContext(ctx, name, args, "", "") +func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) *ToolResult { + return r.ExecuteWithContext(ctx, name, args, "", "", nil) } -func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}, channel, chatID string) (string, error) { +// ExecuteWithContext executes a tool with channel/chatID context and optional async callback. +// If the tool implements AsyncTool and a non-nil callback is provided, +// the callback will be set on the tool before execution. +func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}, channel, chatID string, asyncCallback AsyncCallback) *ToolResult { logger.InfoCF("tool", "Tool execution started", map[string]interface{}{ "tool": name, @@ -50,7 +54,7 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args map[string]interface{}{ "tool": name, }) - return "", fmt.Errorf("tool '%s' not found", name) + return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) } // If tool implements ContextualTool, set context @@ -58,27 +62,43 @@ func (r *ToolRegistry) ExecuteWithContext(ctx context.Context, name string, args contextualTool.SetContext(channel, chatID) } + // If tool implements AsyncTool and callback is provided, set callback + if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { + asyncTool.SetCallback(asyncCallback) + logger.DebugCF("tool", "Async callback injected", + map[string]interface{}{ + "tool": name, + }) + } + start := time.Now() - result, err := tool.Execute(ctx, args) + result := tool.Execute(ctx, args) duration := time.Since(start) - if err != nil { + // Log based on result type + if result.IsError { logger.ErrorCF("tool", "Tool execution failed", map[string]interface{}{ "tool": name, "duration": duration.Milliseconds(), - "error": err.Error(), + "error": result.ForLLM, + }) + } else if result.Async { + logger.InfoCF("tool", "Tool started (async)", + map[string]interface{}{ + "tool": name, + "duration": duration.Milliseconds(), }) } else { logger.InfoCF("tool", "Tool execution completed", map[string]interface{}{ "tool": name, "duration_ms": duration.Milliseconds(), - "result_length": len(result), + "result_length": len(result.ForLLM), }) } - return result, err + return result } func (r *ToolRegistry) GetDefinitions() []map[string]interface{} { @@ -92,6 +112,38 @@ func (r *ToolRegistry) GetDefinitions() []map[string]interface{} { return definitions } +// ToProviderDefs converts tool definitions to provider-compatible format. +// This is the format expected by LLM provider APIs. +func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + + definitions := make([]providers.ToolDefinition, 0, len(r.tools)) + for _, tool := range r.tools { + schema := ToolToSchema(tool) + + // Safely extract nested values with type checks + fn, ok := schema["function"].(map[string]interface{}) + if !ok { + continue + } + + name, _ := fn["name"].(string) + desc, _ := fn["description"].(string) + params, _ := fn["parameters"].(map[string]interface{}) + + definitions = append(definitions, providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: name, + Description: desc, + Parameters: params, + }, + }) + } + return definitions +} + // List returns a list of all registered tool names. func (r *ToolRegistry) List() []string { r.mu.RLock() diff --git a/pkg/tools/result.go b/pkg/tools/result.go new file mode 100644 index 000000000..b13055b1c --- /dev/null +++ b/pkg/tools/result.go @@ -0,0 +1,143 @@ +package tools + +import "encoding/json" + +// ToolResult represents the structured return value from tool execution. +// It provides clear semantics for different types of results and supports +// async operations, user-facing messages, and error handling. +type ToolResult struct { + // ForLLM is the content sent to the LLM for context. + // Required for all results. + ForLLM string `json:"for_llm"` + + // ForUser is the content sent directly to the user. + // If empty, no user message is sent. + // Silent=true overrides this field. + ForUser string `json:"for_user,omitempty"` + + // Silent suppresses sending any message to the user. + // When true, ForUser is ignored even if set. + Silent bool `json:"silent"` + + // IsError indicates whether the tool execution failed. + // When true, the result should be treated as an error. + IsError bool `json:"is_error"` + + // Async indicates whether the tool is running asynchronously. + // When true, the tool will complete later and notify via callback. + Async bool `json:"async"` + + // Err is the underlying error (not JSON serialized). + // Used for internal error handling and logging. + Err error `json:"-"` +} + +// NewToolResult creates a basic ToolResult with content for the LLM. +// Use this when you need a simple result with default behavior. +// +// Example: +// +// result := NewToolResult("File updated successfully") +func NewToolResult(forLLM string) *ToolResult { + return &ToolResult{ + ForLLM: forLLM, + } +} + +// SilentResult creates a ToolResult that is silent (no user message). +// The content is only sent to the LLM for context. +// +// Use this for operations that should not spam the user, such as: +// - File reads/writes +// - Status updates +// - Background operations +// +// Example: +// +// result := SilentResult("Config file saved") +func SilentResult(forLLM string) *ToolResult { + return &ToolResult{ + ForLLM: forLLM, + Silent: true, + IsError: false, + Async: false, + } +} + +// AsyncResult creates a ToolResult for async operations. +// The task will run in the background and complete later. +// +// Use this for long-running operations like: +// - Subagent spawns +// - Background processing +// - External API calls with callbacks +// +// Example: +// +// result := AsyncResult("Subagent spawned, will report back") +func AsyncResult(forLLM string) *ToolResult { + return &ToolResult{ + ForLLM: forLLM, + Silent: false, + IsError: false, + Async: true, + } +} + +// ErrorResult creates a ToolResult representing an error. +// Sets IsError=true and includes the error message. +// +// Example: +// +// result := ErrorResult("Failed to connect to database: connection refused") +func ErrorResult(message string) *ToolResult { + return &ToolResult{ + ForLLM: message, + Silent: false, + IsError: true, + Async: false, + } +} + +// UserResult creates a ToolResult with content for both LLM and user. +// Both ForLLM and ForUser are set to the same content. +// +// Use this when the user needs to see the result directly: +// - Command execution output +// - Fetched web content +// - Query results +// +// Example: +// +// result := UserResult("Total files found: 42") +func UserResult(content string) *ToolResult { + return &ToolResult{ + ForLLM: content, + ForUser: content, + Silent: false, + IsError: false, + Async: false, + } +} + +// MarshalJSON implements custom JSON serialization. +// The Err field is excluded from JSON output via the json:"-" tag. +func (tr *ToolResult) MarshalJSON() ([]byte, error) { + type Alias ToolResult + return json.Marshal(&struct { + *Alias + }{ + Alias: (*Alias)(tr), + }) +} + +// WithError sets the Err field and returns the result for chaining. +// This preserves the error for logging while keeping it out of JSON. +// +// Example: +// +// result := ErrorResult("Operation failed").WithError(err) +func (tr *ToolResult) WithError(err error) *ToolResult { + tr.Err = err + return tr +} diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go new file mode 100644 index 000000000..bc798cd70 --- /dev/null +++ b/pkg/tools/result_test.go @@ -0,0 +1,229 @@ +package tools + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestNewToolResult(t *testing.T) { + result := NewToolResult("test content") + + if result.ForLLM != "test content" { + t.Errorf("Expected ForLLM 'test content', got '%s'", result.ForLLM) + } + if result.Silent { + t.Error("Expected Silent to be false") + } + if result.IsError { + t.Error("Expected IsError to be false") + } + if result.Async { + t.Error("Expected Async to be false") + } +} + +func TestSilentResult(t *testing.T) { + result := SilentResult("silent operation") + + if result.ForLLM != "silent operation" { + t.Errorf("Expected ForLLM 'silent operation', got '%s'", result.ForLLM) + } + if !result.Silent { + t.Error("Expected Silent to be true") + } + if result.IsError { + t.Error("Expected IsError to be false") + } + if result.Async { + t.Error("Expected Async to be false") + } +} + +func TestAsyncResult(t *testing.T) { + result := AsyncResult("async task started") + + if result.ForLLM != "async task started" { + t.Errorf("Expected ForLLM 'async task started', got '%s'", result.ForLLM) + } + if result.Silent { + t.Error("Expected Silent to be false") + } + if result.IsError { + t.Error("Expected IsError to be false") + } + if !result.Async { + t.Error("Expected Async to be true") + } +} + +func TestErrorResult(t *testing.T) { + result := ErrorResult("operation failed") + + if result.ForLLM != "operation failed" { + t.Errorf("Expected ForLLM 'operation failed', got '%s'", result.ForLLM) + } + if result.Silent { + t.Error("Expected Silent to be false") + } + if !result.IsError { + t.Error("Expected IsError to be true") + } + if result.Async { + t.Error("Expected Async to be false") + } +} + +func TestUserResult(t *testing.T) { + content := "user visible message" + result := UserResult(content) + + if result.ForLLM != content { + t.Errorf("Expected ForLLM '%s', got '%s'", content, result.ForLLM) + } + if result.ForUser != content { + t.Errorf("Expected ForUser '%s', got '%s'", content, result.ForUser) + } + if result.Silent { + t.Error("Expected Silent to be false") + } + if result.IsError { + t.Error("Expected IsError to be false") + } + if result.Async { + t.Error("Expected Async to be false") + } +} + +func TestToolResultJSONSerialization(t *testing.T) { + tests := []struct { + name string + result *ToolResult + }{ + { + name: "basic result", + result: NewToolResult("basic content"), + }, + { + name: "silent result", + result: SilentResult("silent content"), + }, + { + name: "async result", + result: AsyncResult("async content"), + }, + { + name: "error result", + result: ErrorResult("error content"), + }, + { + name: "user result", + result: UserResult("user content"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal to JSON + data, err := json.Marshal(tt.result) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + // Unmarshal back + var decoded ToolResult + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + // Verify fields match (Err should be excluded) + if decoded.ForLLM != tt.result.ForLLM { + t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM) + } + if decoded.ForUser != tt.result.ForUser { + t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser) + } + if decoded.Silent != tt.result.Silent { + t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent) + } + if decoded.IsError != tt.result.IsError { + t.Errorf("IsError mismatch: got %v, want %v", decoded.IsError, tt.result.IsError) + } + if decoded.Async != tt.result.Async { + t.Errorf("Async mismatch: got %v, want %v", decoded.Async, tt.result.Async) + } + }) + } +} + +func TestToolResultWithErrors(t *testing.T) { + err := errors.New("underlying error") + result := ErrorResult("error message").WithError(err) + + if result.Err == nil { + t.Error("Expected Err to be set") + } + if result.Err.Error() != "underlying error" { + t.Errorf("Expected Err message 'underlying error', got '%s'", result.Err.Error()) + } + + // Verify Err is not serialized + data, marshalErr := json.Marshal(result) + if marshalErr != nil { + t.Fatalf("Failed to marshal: %v", marshalErr) + } + + var decoded ToolResult + if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil { + t.Fatalf("Failed to unmarshal: %v", unmarshalErr) + } + + if decoded.Err != nil { + t.Error("Expected Err to be nil after JSON round-trip (should not be serialized)") + } +} + +func TestToolResultJSONStructure(t *testing.T) { + result := UserResult("test content") + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + // Verify JSON structure + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + // Check expected keys exist + if _, ok := parsed["for_llm"]; !ok { + t.Error("Expected 'for_llm' key in JSON") + } + if _, ok := parsed["for_user"]; !ok { + t.Error("Expected 'for_user' key in JSON") + } + if _, ok := parsed["silent"]; !ok { + t.Error("Expected 'silent' key in JSON") + } + if _, ok := parsed["is_error"]; !ok { + t.Error("Expected 'is_error' key in JSON") + } + if _, ok := parsed["async"]; !ok { + t.Error("Expected 'async' key in JSON") + } + + // Check that 'err' is NOT present (it should have json:"-" tag) + if _, ok := parsed["err"]; ok { + t.Error("Expected 'err' key to be excluded from JSON") + } + + // Verify values + if parsed["for_llm"] != "test content" { + t.Errorf("Expected for_llm 'test content', got %v", parsed["for_llm"]) + } + if parsed["silent"] != false { + t.Errorf("Expected silent false, got %v", parsed["silent"]) + } +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d8aea40a8..d9430672f 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -8,8 +8,11 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) type ExecTool struct { @@ -20,16 +23,82 @@ type ExecTool struct { restrictToWorkspace bool } -func NewExecTool(workingDir string) *ExecTool { - denyPatterns := []*regexp.Regexp{ - regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), - regexp.MustCompile(`\bdel\s+/[fq]\b`), - regexp.MustCompile(`\brmdir\s+/s\b`), - regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) - regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) - regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), +var defaultDenyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), + regexp.MustCompile(`\bdel\s+/[fq]\b`), + regexp.MustCompile(`\brmdir\s+/s\b`), + regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) + regexp.MustCompile(`\bdd\s+if=`), + regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) + regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), +} + +func NewExecTool(workingDir string, restrict bool) *ExecTool { + return NewExecToolWithConfig(workingDir, restrict, nil) +} + +func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool { + denyPatterns := make([]*regexp.Regexp, 0) + + enableDenyPatterns := true + if config != nil { + execConfig := config.Tools.Exec + enableDenyPatterns = execConfig.EnableDenyPatterns + if enableDenyPatterns { + if len(execConfig.CustomDenyPatterns) > 0 { + fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) + for _, pattern := range execConfig.CustomDenyPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err) + continue + } + denyPatterns = append(denyPatterns, re) + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) + } + } else { + // If deny patterns are disabled, we won't add any patterns, allowing all commands. + fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) } return &ExecTool{ @@ -37,7 +106,7 @@ func NewExecTool(workingDir string) *ExecTool { timeout: 60 * time.Second, denyPatterns: denyPatterns, allowPatterns: nil, - restrictToWorkspace: false, + restrictToWorkspace: restrict, } } @@ -66,10 +135,10 @@ func (t *ExecTool) Parameters() map[string]interface{} { } } -func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { command, ok := args["command"].(string) if !ok { - return "", fmt.Errorf("command is required") + return ErrorResult("command is required") } cwd := t.workingDir @@ -85,13 +154,25 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st } if guardError := t.guardCommand(command, cwd); guardError != "" { - return fmt.Sprintf("Error: %s", guardError), nil + return ErrorResult(guardError) } - cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) + // timeout == 0 means no timeout + var cmdCtx context.Context + var cancel context.CancelFunc + if t.timeout > 0 { + cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) + } else { + cmdCtx, cancel = context.WithCancel(ctx) + } defer cancel() - cmd := exec.CommandContext(cmdCtx, "sh", "-c", command) + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) + } else { + cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) + } if cwd != "" { cmd.Dir = cwd } @@ -108,7 +189,12 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st if err != nil { if cmdCtx.Err() == context.DeadlineExceeded { - return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil + msg := fmt.Sprintf("Command timed out after %v", t.timeout) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + IsError: true, + } } output += fmt.Sprintf("\nExit code: %v", err) } @@ -122,7 +208,19 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) } - return output, nil + if err != nil { + return &ToolResult{ + ForLLM: output, + ForUser: output, + IsError: true, + } + } + + return &ToolResult{ + ForLLM: output, + ForUser: output, + IsError: false, + } } func (t *ExecTool) guardCommand(command, cwd string) string { diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go new file mode 100644 index 000000000..c06468a39 --- /dev/null +++ b/pkg/tools/shell_test.go @@ -0,0 +1,210 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestShellTool_Success verifies successful command execution +func TestShellTool_Success(t *testing.T) { + tool := NewExecTool("", false) + + ctx := context.Background() + args := map[string]interface{}{ + "command": "echo 'hello world'", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain command output + if !strings.Contains(result.ForUser, "hello world") { + t.Errorf("Expected ForUser to contain 'hello world', got: %s", result.ForUser) + } + + // ForLLM should contain full output + if !strings.Contains(result.ForLLM, "hello world") { + t.Errorf("Expected ForLLM to contain 'hello world', got: %s", result.ForLLM) + } +} + +// TestShellTool_Failure verifies failed command execution +func TestShellTool_Failure(t *testing.T) { + tool := NewExecTool("", false) + + ctx := context.Background() + args := map[string]interface{}{ + "command": "ls /nonexistent_directory_12345", + } + + result := tool.Execute(ctx, args) + + // Failure should be marked as error + if !result.IsError { + t.Errorf("Expected error for failed command, got IsError=false") + } + + // ForUser should contain error information + if result.ForUser == "" { + t.Errorf("Expected ForUser to contain error info, got empty string") + } + + // ForLLM should contain exit code or error + if !strings.Contains(result.ForLLM, "Exit code") && result.ForUser == "" { + t.Errorf("Expected ForLLM to contain exit code or error, got: %s", result.ForLLM) + } +} + +// TestShellTool_Timeout verifies command timeout handling +func TestShellTool_Timeout(t *testing.T) { + tool := NewExecTool("", false) + tool.SetTimeout(100 * time.Millisecond) + + ctx := context.Background() + args := map[string]interface{}{ + "command": "sleep 10", + } + + result := tool.Execute(ctx, args) + + // Timeout should be marked as error + if !result.IsError { + t.Errorf("Expected error for timeout, got IsError=false") + } + + // Should mention timeout + if !strings.Contains(result.ForLLM, "timed out") && !strings.Contains(result.ForUser, "timed out") { + t.Errorf("Expected timeout message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + } +} + +// TestShellTool_WorkingDir verifies custom working directory +func TestShellTool_WorkingDir(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + os.WriteFile(testFile, []byte("test content"), 0644) + + tool := NewExecTool("", false) + + ctx := context.Background() + args := map[string]interface{}{ + "command": "cat test.txt", + "working_dir": tmpDir, + } + + result := tool.Execute(ctx, args) + + if result.IsError { + t.Errorf("Expected success in custom working dir, got error: %s", result.ForLLM) + } + + if !strings.Contains(result.ForUser, "test content") { + t.Errorf("Expected output from custom dir, got: %s", result.ForUser) + } +} + +// TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands +func TestShellTool_DangerousCommand(t *testing.T) { + tool := NewExecTool("", false) + + ctx := context.Background() + args := map[string]interface{}{ + "command": "rm -rf /", + } + + result := tool.Execute(ctx, args) + + // Dangerous command should be blocked + if !result.IsError { + t.Errorf("Expected dangerous command to be blocked (IsError=true)") + } + + if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { + t.Errorf("Expected 'blocked' message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + } +} + +// TestShellTool_MissingCommand verifies error handling for missing command +func TestShellTool_MissingCommand(t *testing.T) { + tool := NewExecTool("", false) + + ctx := context.Background() + args := map[string]interface{}{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when command is missing") + } +} + +// TestShellTool_StderrCapture verifies stderr is captured and included +func TestShellTool_StderrCapture(t *testing.T) { + tool := NewExecTool("", false) + + ctx := context.Background() + args := map[string]interface{}{ + "command": "sh -c 'echo stdout; echo stderr >&2'", + } + + result := tool.Execute(ctx, args) + + // Both stdout and stderr should be in output + if !strings.Contains(result.ForLLM, "stdout") { + t.Errorf("Expected stdout in output, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "stderr") { + t.Errorf("Expected stderr in output, got: %s", result.ForLLM) + } +} + +// TestShellTool_OutputTruncation verifies long output is truncated +func TestShellTool_OutputTruncation(t *testing.T) { + tool := NewExecTool("", false) + + ctx := context.Background() + // Generate long output (>10000 chars) + args := map[string]interface{}{ + "command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000), + } + + result := tool.Execute(ctx, args) + + // Should have truncation message or be truncated + if len(result.ForLLM) > 15000 { + t.Errorf("Expected output to be truncated, got length: %d", len(result.ForLLM)) + } +} + +// TestShellTool_RestrictToWorkspace verifies workspace restriction +func TestShellTool_RestrictToWorkspace(t *testing.T) { + tmpDir := t.TempDir() + tool := NewExecTool(tmpDir, false) + tool.SetRestrictToWorkspace(true) + + ctx := context.Background() + args := map[string]interface{}{ + "command": "cat ../../etc/passwd", + } + + result := tool.Execute(ctx, args) + + // Path traversal should be blocked + if !result.IsError { + t.Errorf("Expected path traversal to be blocked with restrictToWorkspace=true") + } + + if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { + t.Errorf("Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + } +} diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index 1bd7ac432..f01372467 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -6,9 +6,11 @@ import ( ) type SpawnTool struct { - manager *SubagentManager - originChannel string - originChatID string + manager *SubagentManager + originChannel string + originChatID string + allowlistCheck func(targetAgentID string) bool + callback AsyncCallback // For async completion notification } func NewSpawnTool(manager *SubagentManager) *SpawnTool { @@ -19,6 +21,11 @@ func NewSpawnTool(manager *SubagentManager) *SpawnTool { } } +// SetCallback implements AsyncTool interface for async completion notification +func (t *SpawnTool) SetCallback(cb AsyncCallback) { + t.callback = cb +} + func (t *SpawnTool) Name() string { return "spawn" } @@ -39,6 +46,10 @@ func (t *SpawnTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional short label for the task (for display)", }, + "agent_id": map[string]interface{}{ + "type": "string", + "description": "Optional target agent ID to delegate the task to", + }, }, "required": []string{"task"}, } @@ -49,22 +60,36 @@ func (t *SpawnTool) SetContext(channel, chatID string) { t.originChatID = chatID } -func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { + t.allowlistCheck = check +} + +func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { task, ok := args["task"].(string) if !ok { - return "", fmt.Errorf("task is required") + return ErrorResult("task is required") } label, _ := args["label"].(string) + agentID, _ := args["agent_id"].(string) + + // Check allowlist if targeting a specific agent + if agentID != "" && t.allowlistCheck != nil { + if !t.allowlistCheck(agentID) { + return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID)) + } + } if t.manager == nil { - return "Error: Subagent manager not configured", nil + return ErrorResult("Subagent manager not configured") } - result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID) + // Pass callback to manager for async completion notification + result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback) if err != nil { - return "", fmt.Errorf("failed to spawn subagent: %w", err) + return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) } - return result, nil + // Return AsyncResult since the task runs in background + return AsyncResult(result) } diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go new file mode 100644 index 000000000..4805d6a35 --- /dev/null +++ b/pkg/tools/spi.go @@ -0,0 +1,156 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "runtime" +) + +// SPITool provides SPI bus interaction for high-speed peripheral communication. +type SPITool struct{} + +func NewSPITool() *SPITool { + return &SPITool{} +} + +func (t *SPITool) Name() string { + return "spi" +} + +func (t *SPITool) Description() string { + return "Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only." +} + +func (t *SPITool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "enum": []string{"list", "transfer", "read"}, + "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", + }, + "device": map[string]interface{}{ + "type": "string", + "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", + }, + "speed": map[string]interface{}{ + "type": "integer", + "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", + }, + "mode": map[string]interface{}{ + "type": "integer", + "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", + }, + "bits": map[string]interface{}{ + "type": "integer", + "description": "Bits per word. Default: 8.", + }, + "data": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "integer"}, + "description": "Bytes to send (0-255 each). Required for transfer action.", + }, + "length": map[string]interface{}{ + "type": "integer", + "description": "Number of bytes to read (1-4096). Required for read action.", + }, + "confirm": map[string]interface{}{ + "type": "boolean", + "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *SPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if runtime.GOOS != "linux" { + return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.") + } + + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "list": + return t.list() + case "transfer": + return t.transfer(args) + case "read": + return t.readDevice(args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action)) + } +} + +// list finds available SPI devices by globbing /dev/spidev* +func (t *SPITool) list() *ToolResult { + matches, err := filepath.Glob("/dev/spidev*") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to scan for SPI devices: %v", err)) + } + + if len(matches) == 0 { + return SilentResult("No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded") + } + + type devInfo struct { + Path string `json:"path"` + Device string `json:"device"` + } + + devices := make([]devInfo, 0, len(matches)) + re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`) + for _, m := range matches { + if sub := re.FindStringSubmatch(m); sub != nil { + devices = append(devices, devInfo{Path: m, Device: sub[1]}) + } + } + + result, _ := json.MarshalIndent(devices, "", " ") + return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) +} + +// parseSPIArgs extracts and validates common SPI parameters +func parseSPIArgs(args map[string]interface{}) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { + dev, ok := args["device"].(string) + if !ok || dev == "" { + return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" + } + matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev) + if !matched { + return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")" + } + + speed = 1000000 // default 1 MHz + if s, ok := args["speed"].(float64); ok { + if s < 1 || s > 125000000 { + return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz" + } + speed = uint32(s) + } + + mode = 0 + if m, ok := args["mode"].(float64); ok { + if int(m) < 0 || int(m) > 3 { + return "", 0, 0, 0, "mode must be 0-3" + } + mode = uint8(m) + } + + bits = 8 + if b, ok := args["bits"].(float64); ok { + if int(b) < 1 || int(b) > 32 { + return "", 0, 0, 0, "bits must be between 1 and 32" + } + bits = uint8(b) + } + + return dev, speed, mode, bits, "" +} diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go new file mode 100644 index 000000000..12b696007 --- /dev/null +++ b/pkg/tools/spi_linux.go @@ -0,0 +1,196 @@ +package tools + +import ( + "encoding/json" + "fmt" + "runtime" + "syscall" + "unsafe" +) + +// SPI ioctl constants from Linux kernel headers. +// Calculated from _IOW('k', nr, size) macro: +// +// direction(1)<<30 | size<<16 | type(0x6B)<<8 | nr +const ( + spiIocWrMode = 0x40016B01 // _IOW('k', 1, __u8) + spiIocWrBitsPerWord = 0x40016B03 // _IOW('k', 3, __u8) + spiIocWrMaxSpeedHz = 0x40046B04 // _IOW('k', 4, __u32) + spiIocMessage1 = 0x40206B00 // _IOW('k', 0, struct spi_ioc_transfer) — 32 bytes +) + +// spiTransfer matches Linux kernel struct spi_ioc_transfer (32 bytes on all architectures). +type spiTransfer struct { + txBuf uint64 + rxBuf uint64 + length uint32 + speedHz uint32 + delayUsecs uint16 + bitsPerWord uint8 + csChange uint8 + txNbits uint8 + rxNbits uint8 + wordDelay uint8 + pad uint8 +} + +// configureSPI opens an SPI device and sets mode, bits per word, and speed +func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { + fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) + if err != nil { + return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) + } + + // Set SPI mode + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno)) + } + + // Set bits per word + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno)) + } + + // Set max speed + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed))) + if errno != 0 { + syscall.Close(fd) + return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno)) + } + + return fd, nil +} + +// transfer performs a full-duplex SPI transfer +func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { + confirm, _ := args["confirm"].(bool) + if !confirm { + return ErrorResult("transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.") + } + + dev, speed, mode, bits, errMsg := parseSPIArgs(args) + if errMsg != "" { + return ErrorResult(errMsg) + } + + dataRaw, ok := args["data"].([]interface{}) + if !ok || len(dataRaw) == 0 { + return ErrorResult("data is required for transfer (array of byte values 0-255)") + } + if len(dataRaw) > 4096 { + return ErrorResult("data too long: maximum 4096 bytes per SPI transfer") + } + + txBuf := make([]byte, len(dataRaw)) + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + txBuf[i] = byte(b) + } + + devPath := fmt.Sprintf("/dev/spidev%s", dev) + fd, errResult := configureSPI(devPath, mode, bits, speed) + if errResult != nil { + return errResult + } + defer syscall.Close(fd) + + rxBuf := make([]byte, len(txBuf)) + + xfer := spiTransfer{ + txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), + rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), + length: uint32(len(txBuf)), + speedHz: speed, + bitsPerWord: bits, + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + runtime.KeepAlive(txBuf) + runtime.KeepAlive(rxBuf) + if errno != 0 { + return ErrorResult(fmt.Sprintf("SPI transfer failed: %v", errno)) + } + + // Format received bytes + hexBytes := make([]string, len(rxBuf)) + intBytes := make([]int, len(rxBuf)) + for i, b := range rxBuf { + hexBytes[i] = fmt.Sprintf("0x%02x", b) + intBytes[i] = int(b) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "device": devPath, + "sent": len(txBuf), + "received": intBytes, + "hex": hexBytes, + }, "", " ") + return SilentResult(string(result)) +} + +// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed) +func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { + dev, speed, mode, bits, errMsg := parseSPIArgs(args) + if errMsg != "" { + return ErrorResult(errMsg) + } + + length := 0 + if l, ok := args["length"].(float64); ok { + length = int(l) + } + if length < 1 || length > 4096 { + return ErrorResult("length is required for read (1-4096)") + } + + devPath := fmt.Sprintf("/dev/spidev%s", dev) + fd, errResult := configureSPI(devPath, mode, bits, speed) + if errResult != nil { + return errResult + } + defer syscall.Close(fd) + + txBuf := make([]byte, length) // zeros + rxBuf := make([]byte, length) + + xfer := spiTransfer{ + txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), + rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), + length: uint32(length), + speedHz: speed, + bitsPerWord: bits, + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + runtime.KeepAlive(txBuf) + runtime.KeepAlive(rxBuf) + if errno != 0 { + return ErrorResult(fmt.Sprintf("SPI read failed: %v", errno)) + } + + hexBytes := make([]string, len(rxBuf)) + intBytes := make([]int, len(rxBuf)) + for i, b := range rxBuf { + hexBytes[i] = fmt.Sprintf("0x%02x", b) + intBytes[i] = int(b) + } + + result, _ := json.MarshalIndent(map[string]interface{}{ + "device": devPath, + "bytes": intBytes, + "hex": hexBytes, + "length": len(rxBuf), + }, "", " ") + return SilentResult(string(result)) +} diff --git a/pkg/tools/spi_other.go b/pkg/tools/spi_other.go new file mode 100644 index 000000000..6dfc86fd1 --- /dev/null +++ b/pkg/tools/spi_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package tools + +// transfer is a stub for non-Linux platforms. +func (t *SPITool) transfer(args map[string]interface{}) *ToolResult { + return ErrorResult("SPI is only supported on Linux") +} + +// readDevice is a stub for non-Linux platforms. +func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult { + return ErrorResult("SPI is only supported on Linux") +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 0c05097f0..2fc7162d0 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -14,6 +14,7 @@ type SubagentTask struct { ID string Task string Label string + AgentID string OriginChannel string OriginChatID string Status string @@ -22,25 +23,46 @@ type SubagentTask struct { } type SubagentManager struct { - tasks map[string]*SubagentTask - mu sync.RWMutex - provider providers.LLMProvider - bus *bus.MessageBus - workspace string - nextID int + tasks map[string]*SubagentTask + mu sync.RWMutex + provider providers.LLMProvider + defaultModel string + bus *bus.MessageBus + workspace string + tools *ToolRegistry + maxIterations int + nextID int } -func NewSubagentManager(provider providers.LLMProvider, workspace string, bus *bus.MessageBus) *SubagentManager { +func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager { return &SubagentManager{ - tasks: make(map[string]*SubagentTask), - provider: provider, - bus: bus, - workspace: workspace, - nextID: 1, + tasks: make(map[string]*SubagentTask), + provider: provider, + defaultModel: defaultModel, + bus: bus, + workspace: workspace, + tools: NewToolRegistry(), + maxIterations: 10, + nextID: 1, } } -func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string) (string, error) { +// SetTools sets the tool registry for subagent execution. +// If not set, subagent will have access to the provided tools. +func (sm *SubagentManager) SetTools(tools *ToolRegistry) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.tools = tools +} + +// RegisterTool registers a tool for subagent execution. +func (sm *SubagentManager) RegisterTool(tool Tool) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.tools.Register(tool) +} + +func (sm *SubagentManager) Spawn(ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback) (string, error) { sm.mu.Lock() defer sm.mu.Unlock() @@ -51,6 +73,7 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel ID: taskID, Task: task, Label: label, + AgentID: agentID, OriginChannel: originChannel, OriginChatID: originChatID, Status: "running", @@ -58,7 +81,8 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel } sm.tasks[taskID] = subagentTask - go sm.runTask(ctx, subagentTask) + // Start task in background with context cancellation support + go sm.runTask(ctx, subagentTask, callback) if label != "" { return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil @@ -66,14 +90,19 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel return fmt.Sprintf("Spawned subagent for task: %s", task), nil } -func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { +func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) { task.Status = "running" task.Created = time.Now().UnixMilli() + // Build system prompt for subagent + systemPrompt := `You are a subagent. Complete the given task independently and report the result. +You have access to tools - use them as needed to complete your task. +After completing the task, provide a clear summary of what was done.` + messages := []providers.Message{ { Role: "system", - Content: "You are a subagent. Complete the given task independently and report the result.", + Content: systemPrompt, }, { Role: "user", @@ -81,19 +110,70 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { }, } - response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{ - "max_tokens": 4096, - }) + // Check if context is already cancelled before starting + select { + case <-ctx.Done(): + sm.mu.Lock() + task.Status = "cancelled" + task.Result = "Task cancelled before execution" + sm.mu.Unlock() + return + default: + } + + // Run tool loop with access to tools + sm.mu.RLock() + tools := sm.tools + maxIter := sm.maxIterations + sm.mu.RUnlock() + + loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ + Provider: sm.provider, + Model: sm.defaultModel, + Tools: tools, + MaxIterations: maxIter, + LLMOptions: map[string]any{ + "max_tokens": 4096, + "temperature": 0.7, + }, + }, messages, task.OriginChannel, task.OriginChatID) sm.mu.Lock() - defer sm.mu.Unlock() + var result *ToolResult + defer func() { + sm.mu.Unlock() + // Call callback if provided and result is set + if callback != nil && result != nil { + callback(ctx, result) + } + }() if err != nil { task.Status = "failed" task.Result = fmt.Sprintf("Error: %v", err) + // Check if it was cancelled + if ctx.Err() != nil { + task.Status = "cancelled" + task.Result = "Task cancelled during execution" + } + result = &ToolResult{ + ForLLM: task.Result, + ForUser: "", + Silent: false, + IsError: true, + Async: false, + Err: err, + } } else { task.Status = "completed" - task.Result = response.Content + task.Result = loopResult.Content + result = &ToolResult{ + ForLLM: fmt.Sprintf("Subagent '%s' completed (iterations: %d): %s", task.Label, loopResult.Iterations, loopResult.Content), + ForUser: loopResult.Content, + Silent: false, + IsError: false, + Async: false, + } } // Send announce message back to main agent @@ -126,3 +206,120 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask { } return tasks } + +// SubagentTool executes a subagent task synchronously and returns the result. +// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion +// and returns the result directly in the ToolResult. +type SubagentTool struct { + manager *SubagentManager + originChannel string + originChatID string +} + +func NewSubagentTool(manager *SubagentManager) *SubagentTool { + return &SubagentTool{ + manager: manager, + originChannel: "cli", + originChatID: "direct", + } +} + +func (t *SubagentTool) Name() string { + return "subagent" +} + +func (t *SubagentTool) Description() string { + return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM." +} + +func (t *SubagentTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "task": map[string]interface{}{ + "type": "string", + "description": "The task for subagent to complete", + }, + "label": map[string]interface{}{ + "type": "string", + "description": "Optional short label for the task (for display)", + }, + }, + "required": []string{"task"}, + } +} + +func (t *SubagentTool) SetContext(channel, chatID string) { + t.originChannel = channel + t.originChatID = chatID +} + +func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + task, ok := args["task"].(string) + if !ok { + return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required")) + } + + label, _ := args["label"].(string) + + if t.manager == nil { + return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil")) + } + + // Build messages for subagent + messages := []providers.Message{ + { + Role: "system", + Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", + }, + { + Role: "user", + Content: task, + }, + } + + // Use RunToolLoop to execute with tools (same as async SpawnTool) + sm := t.manager + sm.mu.RLock() + tools := sm.tools + maxIter := sm.maxIterations + sm.mu.RUnlock() + + loopResult, err := RunToolLoop(ctx, ToolLoopConfig{ + Provider: sm.provider, + Model: sm.defaultModel, + Tools: tools, + MaxIterations: maxIter, + LLMOptions: map[string]any{ + "max_tokens": 4096, + "temperature": 0.7, + }, + }, messages, t.originChannel, t.originChatID) + + if err != nil { + return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err) + } + + // ForUser: Brief summary for user (truncated if too long) + userContent := loopResult.Content + maxUserLen := 500 + if len(userContent) > maxUserLen { + userContent = userContent[:maxUserLen] + "..." + } + + // ForLLM: Full execution details + labelStr := label + if labelStr == "" { + labelStr = "(unnamed)" + } + llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nIterations: %d\nResult: %s", + labelStr, loopResult.Iterations, loopResult.Content) + + return &ToolResult{ + ForLLM: llmContent, + ForUser: userContent, + Silent: false, + IsError: false, + Async: false, + } +} diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go new file mode 100644 index 000000000..8a7d22f24 --- /dev/null +++ b/pkg/tools/subagent_tool_test.go @@ -0,0 +1,315 @@ +package tools + +import ( + "context" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// MockLLMProvider is a test implementation of LLMProvider +type MockLLMProvider struct{} + +func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { + // Find the last user message to generate a response + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "user" { + return &providers.LLMResponse{ + Content: "Task completed: " + messages[i].Content, + }, nil + } + } + return &providers.LLMResponse{Content: "No task provided"}, nil +} + +func (m *MockLLMProvider) GetDefaultModel() string { + return "test-model" +} + +func (m *MockLLMProvider) SupportsTools() bool { + return false +} + +func (m *MockLLMProvider) GetContextWindow() int { + return 4096 +} + +// TestSubagentTool_Name verifies tool name +func TestSubagentTool_Name(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + tool := NewSubagentTool(manager) + + if tool.Name() != "subagent" { + t.Errorf("Expected name 'subagent', got '%s'", tool.Name()) + } +} + +// TestSubagentTool_Description verifies tool description +func TestSubagentTool_Description(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + tool := NewSubagentTool(manager) + + desc := tool.Description() + if desc == "" { + t.Error("Description should not be empty") + } + if !strings.Contains(desc, "subagent") { + t.Errorf("Description should mention 'subagent', got: %s", desc) + } +} + +// TestSubagentTool_Parameters verifies tool parameters schema +func TestSubagentTool_Parameters(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + tool := NewSubagentTool(manager) + + params := tool.Parameters() + if params == nil { + t.Error("Parameters should not be nil") + } + + // Check type + if params["type"] != "object" { + t.Errorf("Expected type 'object', got: %v", params["type"]) + } + + // Check properties + props, ok := params["properties"].(map[string]interface{}) + if !ok { + t.Fatal("Properties should be a map") + } + + // Verify task parameter + task, ok := props["task"].(map[string]interface{}) + if !ok { + t.Fatal("Task parameter should exist") + } + if task["type"] != "string" { + t.Errorf("Task type should be 'string', got: %v", task["type"]) + } + + // Verify label parameter + label, ok := props["label"].(map[string]interface{}) + if !ok { + t.Fatal("Label parameter should exist") + } + if label["type"] != "string" { + t.Errorf("Label type should be 'string', got: %v", label["type"]) + } + + // Check required fields + required, ok := params["required"].([]string) + if !ok { + t.Fatal("Required should be a string array") + } + if len(required) != 1 || required[0] != "task" { + t.Errorf("Required should be ['task'], got: %v", required) + } +} + +// TestSubagentTool_SetContext verifies context setting +func TestSubagentTool_SetContext(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + tool := NewSubagentTool(manager) + + tool.SetContext("test-channel", "test-chat") + + // Verify context is set (we can't directly access private fields, + // but we can verify it doesn't crash) + // The actual context usage is tested in Execute tests +} + +// TestSubagentTool_Execute_Success tests successful execution +func TestSubagentTool_Execute_Success(t *testing.T) { + provider := &MockLLMProvider{} + msgBus := bus.NewMessageBus() + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + tool := NewSubagentTool(manager) + tool.SetContext("telegram", "chat-123") + + ctx := context.Background() + args := map[string]interface{}{ + "task": "Write a haiku about coding", + "label": "haiku-task", + } + + result := tool.Execute(ctx, args) + + // Verify basic ToolResult structure + if result == nil { + t.Fatal("Result should not be nil") + } + + // Verify no error + if result.IsError { + t.Errorf("Expected success, got error: %s", result.ForLLM) + } + + // Verify not async + if result.Async { + t.Error("SubagentTool should be synchronous, not async") + } + + // Verify not silent + if result.Silent { + t.Error("SubagentTool should not be silent") + } + + // Verify ForUser contains brief summary (not empty) + if result.ForUser == "" { + t.Error("ForUser should contain result summary") + } + if !strings.Contains(result.ForUser, "Task completed") { + t.Errorf("ForUser should contain task completion, got: %s", result.ForUser) + } + + // Verify ForLLM contains full details + if result.ForLLM == "" { + t.Error("ForLLM should contain full details") + } + if !strings.Contains(result.ForLLM, "haiku-task") { + t.Errorf("ForLLM should contain label 'haiku-task', got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Task completed:") { + t.Errorf("ForLLM should contain task result, got: %s", result.ForLLM) + } +} + +// TestSubagentTool_Execute_NoLabel tests execution without label +func TestSubagentTool_Execute_NoLabel(t *testing.T) { + provider := &MockLLMProvider{} + msgBus := bus.NewMessageBus() + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + tool := NewSubagentTool(manager) + + ctx := context.Background() + args := map[string]interface{}{ + "task": "Test task without label", + } + + result := tool.Execute(ctx, args) + + if result.IsError { + t.Errorf("Expected success without label, got error: %s", result.ForLLM) + } + + // ForLLM should show (unnamed) for missing label + if !strings.Contains(result.ForLLM, "(unnamed)") { + t.Errorf("ForLLM should show '(unnamed)' for missing label, got: %s", result.ForLLM) + } +} + +// TestSubagentTool_Execute_MissingTask tests error handling for missing task +func TestSubagentTool_Execute_MissingTask(t *testing.T) { + provider := &MockLLMProvider{} + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil) + tool := NewSubagentTool(manager) + + ctx := context.Background() + args := map[string]interface{}{ + "label": "test", + } + + result := tool.Execute(ctx, args) + + // Should return error + if !result.IsError { + t.Error("Expected error for missing task parameter") + } + + // ForLLM should contain error message + if !strings.Contains(result.ForLLM, "task is required") { + t.Errorf("Error message should mention 'task is required', got: %s", result.ForLLM) + } + + // Err should be set + if result.Err == nil { + t.Error("Err should be set for validation failure") + } +} + +// TestSubagentTool_Execute_NilManager tests error handling for nil manager +func TestSubagentTool_Execute_NilManager(t *testing.T) { + tool := NewSubagentTool(nil) + + ctx := context.Background() + args := map[string]interface{}{ + "task": "test task", + } + + result := tool.Execute(ctx, args) + + // Should return error + if !result.IsError { + t.Error("Expected error for nil manager") + } + + if !strings.Contains(result.ForLLM, "Subagent manager not configured") { + t.Errorf("Error message should mention manager not configured, got: %s", result.ForLLM) + } +} + +// TestSubagentTool_Execute_ContextPassing verifies context is properly used +func TestSubagentTool_Execute_ContextPassing(t *testing.T) { + provider := &MockLLMProvider{} + msgBus := bus.NewMessageBus() + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + tool := NewSubagentTool(manager) + + // Set context + channel := "test-channel" + chatID := "test-chat" + tool.SetContext(channel, chatID) + + ctx := context.Background() + args := map[string]interface{}{ + "task": "Test context passing", + } + + result := tool.Execute(ctx, args) + + // Should succeed + if result.IsError { + t.Errorf("Expected success with context, got error: %s", result.ForLLM) + } + + // The context is used internally; we can't directly test it + // but execution success indicates context was handled properly +} + +// TestSubagentTool_ForUserTruncation verifies long content is truncated for user +func TestSubagentTool_ForUserTruncation(t *testing.T) { + // Create a mock provider that returns very long content + provider := &MockLLMProvider{} + msgBus := bus.NewMessageBus() + manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus) + tool := NewSubagentTool(manager) + + ctx := context.Background() + + // Create a task that will generate long response + longTask := strings.Repeat("This is a very long task description. ", 100) + args := map[string]interface{}{ + "task": longTask, + "label": "long-test", + } + + result := tool.Execute(ctx, args) + + // ForUser should be truncated to 500 chars + "..." + maxUserLen := 500 + if len(result.ForUser) > maxUserLen+3 { // +3 for "..." + t.Errorf("ForUser should be truncated to ~%d chars, got: %d", maxUserLen, len(result.ForUser)) + } + + // ForLLM should have full content + if !strings.Contains(result.ForLLM, longTask[:50]) { + t.Error("ForLLM should contain reference to original task") + } +} diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go new file mode 100644 index 000000000..1302079b4 --- /dev/null +++ b/pkg/tools/toolloop.go @@ -0,0 +1,154 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// ToolLoopConfig configures the tool execution loop. +type ToolLoopConfig struct { + Provider providers.LLMProvider + Model string + Tools *ToolRegistry + MaxIterations int + LLMOptions map[string]any +} + +// ToolLoopResult contains the result of running the tool loop. +type ToolLoopResult struct { + Content string + Iterations int +} + +// RunToolLoop executes the LLM + tool call iteration loop. +// This is the core agent logic that can be reused by both main agent and subagents. +func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []providers.Message, channel, chatID string) (*ToolLoopResult, error) { + iteration := 0 + var finalContent string + + for iteration < config.MaxIterations { + iteration++ + + logger.DebugCF("toolloop", "LLM iteration", + map[string]any{ + "iteration": iteration, + "max": config.MaxIterations, + }) + + // 1. Build tool definitions + var providerToolDefs []providers.ToolDefinition + if config.Tools != nil { + providerToolDefs = config.Tools.ToProviderDefs() + } + + // 2. Set default LLM options + llmOpts := config.LLMOptions + if llmOpts == nil { + llmOpts = map[string]any{ + "max_tokens": 4096, + "temperature": 0.7, + } + } + + // 3. Call LLM + response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) + if err != nil { + logger.ErrorCF("toolloop", "LLM call failed", + map[string]any{ + "iteration": iteration, + "error": err.Error(), + }) + return nil, fmt.Errorf("LLM call failed: %w", err) + } + + // 4. If no tool calls, we're done + if len(response.ToolCalls) == 0 { + finalContent = response.Content + logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)", + map[string]any{ + "iteration": iteration, + "content_chars": len(finalContent), + }) + break + } + + // 5. Log tool calls + toolNames := make([]string, 0, len(response.ToolCalls)) + for _, tc := range response.ToolCalls { + toolNames = append(toolNames, tc.Name) + } + logger.InfoCF("toolloop", "LLM requested tool calls", + map[string]any{ + "tools": toolNames, + "count": len(response.ToolCalls), + "iteration": iteration, + }) + + // 6. Build assistant message with tool calls + assistantMsg := providers.Message{ + Role: "assistant", + Content: response.Content, + } + for _, tc := range response.ToolCalls { + argumentsJSON, _ := json.Marshal(tc.Arguments) + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ + ID: tc.ID, + Type: "function", + Function: &providers.FunctionCall{ + Name: tc.Name, + Arguments: string(argumentsJSON), + }, + }) + } + messages = append(messages, assistantMsg) + + // 7. Execute tool calls + for _, tc := range response.ToolCalls { + argsJSON, _ := json.Marshal(tc.Arguments) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), + map[string]any{ + "tool": tc.Name, + "iteration": iteration, + }) + + // Execute tool (no async callback for subagents - they run independently) + var toolResult *ToolResult + if config.Tools != nil { + toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) + } else { + toolResult = ErrorResult("No tools available") + } + + // Determine content for LLM + contentForLLM := toolResult.ForLLM + if contentForLLM == "" && toolResult.Err != nil { + contentForLLM = toolResult.Err.Error() + } + + // Add tool result message + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: tc.ID, + } + messages = append(messages, toolResultMsg) + } + } + + return &ToolLoopResult{ + Content: finalContent, + Iterations: iteration, + }, nil +} diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 3a3596865..6a6d40ecf 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -13,20 +13,276 @@ import ( ) const ( - userAgent = "Mozilla/5.0 (compatible; picoclaw/1.0)" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) +type SearchProvider interface { + Search(ctx context.Context, query string, count int) (string, error) +} + +type BraveSearchProvider struct { + apiKey string +} + +func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", + url.QueryEscape(query), count) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Subscription-Token", p.apiKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + var searchResp struct { + Web struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + } `json:"results"` + } `json:"web"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + // Log error body for debugging + fmt.Printf("Brave API Error Body: %s\n", string(body)) + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Web.Results + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Description != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Description)) + } + } + + return strings.Join(lines, "\n"), nil +} + +type DuckDuckGoSearchProvider struct{} + +func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + return p.extractResults(string(body), count, query) +} + +func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { + // Simple regex based extraction for DDG HTML + // Strategy: Find all result containers or key anchors directly + + // Try finding the result links directly first, as they are the most critical + // Pattern: Title + // The previous regex was a bit strict. Let's make it more flexible for attributes order/content + reLink := regexp.MustCompile(`]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)`) + matches := reLink.FindAllStringSubmatch(html, count+5) + + if len(matches) == 0 { + return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via DuckDuckGo)", query)) + + // Pre-compile snippet regex to run inside the loop + // We'll search for snippets relative to the link position or just globally if needed + // But simple global search for snippets might mismatch order. + // Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex) + // Or better: Let's assume the snippet follows the link in the HTML + + // A better regex approach: iterate through text and find matches in order + // But for now, let's grab all snippets too + reSnippet := regexp.MustCompile(`([\s\S]*?)`) + snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5) + + maxItems := min(len(matches), count) + + for i := 0; i < maxItems; i++ { + urlStr := matches[i][1] + title := stripTags(matches[i][2]) + title = strings.TrimSpace(title) + + // URL decoding if needed + if strings.Contains(urlStr, "uddg=") { + if u, err := url.QueryUnescape(urlStr); err == nil { + idx := strings.Index(u, "uddg=") + if idx != -1 { + urlStr = u[idx+5:] + } + } + } + + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr)) + + // Attempt to attach snippet if available and index aligns + if i < len(snippetMatches) { + snippet := stripTags(snippetMatches[i][1]) + snippet = strings.TrimSpace(snippet) + if snippet != "" { + lines = append(lines, fmt.Sprintf(" %s", snippet)) + } + } + } + + return strings.Join(lines, "\n"), nil +} + +func stripTags(content string) string { + re := regexp.MustCompile(`<[^>]+>`) + return re.ReplaceAllString(content, "") +} + +type PerplexitySearchProvider struct { + apiKey string +} + +func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := "https://api.perplexity.ai/chat/completions" + + payload := map[string]interface{}{ + "model": "sonar", + "messages": []map[string]string{ + {"role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary."}, + {"role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count)}, + }, + "max_tokens": 1000, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes))) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Perplexity API error: %s", string(body)) + } + + var searchResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(searchResp.Choices) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil +} + type WebSearchTool struct { - apiKey string + provider SearchProvider maxResults int } -func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool { - if maxResults <= 0 || maxResults > 10 { - maxResults = 5 +type WebSearchToolOptions struct { + BraveAPIKey string + BraveMaxResults int + BraveEnabled bool + DuckDuckGoMaxResults int + DuckDuckGoEnabled bool + PerplexityAPIKey string + PerplexityMaxResults int + PerplexityEnabled bool +} + +func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { + var provider SearchProvider + maxResults := 5 + + // Priority: Perplexity > Brave > DuckDuckGo + if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { + provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey} + if opts.PerplexityMaxResults > 0 { + maxResults = opts.PerplexityMaxResults + } + } else if opts.BraveEnabled && opts.BraveAPIKey != "" { + provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey} + if opts.BraveMaxResults > 0 { + maxResults = opts.BraveMaxResults + } + } else if opts.DuckDuckGoEnabled { + provider = &DuckDuckGoSearchProvider{} + if opts.DuckDuckGoMaxResults > 0 { + maxResults = opts.DuckDuckGoMaxResults + } + } else { + return nil } + return &WebSearchTool{ - apiKey: apiKey, + provider: provider, maxResults: maxResults, } } @@ -58,14 +314,10 @@ func (t *WebSearchTool) Parameters() map[string]interface{} { } } -func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - if t.apiKey == "" { - return "Error: BRAVE_API_KEY not configured", nil - } - +func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { query, ok := args["query"].(string) if !ok { - return "", fmt.Errorf("query is required") + return ErrorResult("query is required") } count := t.maxResults @@ -75,61 +327,15 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{} } } - searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", - url.QueryEscape(query), count) - - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + result, err := t.provider.Search(ctx, query, count) if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) + return ErrorResult(fmt.Sprintf("search failed: %v", err)) } - req.Header.Set("Accept", "application/json") - req.Header.Set("X-Subscription-Token", t.apiKey) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) + return &ToolResult{ + ForLLM: result, + ForUser: result, } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - var searchResp struct { - Web struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Description string `json:"description"` - } `json:"results"` - } `json:"web"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.Web.Results - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil - } - - var lines []string - lines = append(lines, fmt.Sprintf("Results for: %s", query)) - for i, item := range results { - if i >= count { - break - } - lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) - if item.Description != "" { - lines = append(lines, fmt.Sprintf(" %s", item.Description)) - } - } - - return strings.Join(lines, "\n"), nil } type WebFetchTool struct { @@ -171,23 +377,23 @@ func (t *WebFetchTool) Parameters() map[string]interface{} { } } -func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { +func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { urlStr, ok := args["url"].(string) if !ok { - return "", fmt.Errorf("url is required") + return ErrorResult("url is required") } parsedURL, err := url.Parse(urlStr) if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) + return ErrorResult(fmt.Sprintf("invalid URL: %v", err)) } if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return "", fmt.Errorf("only http/https URLs are allowed") + return ErrorResult("only http/https URLs are allowed") } if parsedURL.Host == "" { - return "", fmt.Errorf("missing domain in URL") + return ErrorResult("missing domain in URL") } maxChars := t.maxChars @@ -199,7 +405,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) + return ErrorResult(fmt.Sprintf("failed to create request: %v", err)) } req.Header.Set("User-Agent", userAgent) @@ -222,13 +428,13 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("request failed: %w", err) + return ErrorResult(fmt.Sprintf("request failed: %v", err)) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) + return ErrorResult(fmt.Sprintf("failed to read response: %v", err)) } contentType := resp.Header.Get("Content-Type") @@ -269,7 +475,11 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) } resultJSON, _ := json.MarshalIndent(result, "", " ") - return string(resultJSON), nil + + return &ToolResult{ + ForLLM: fmt.Sprintf("Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated), + ForUser: string(resultJSON), + } } func (t *WebFetchTool) extractText(htmlContent string) string { diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go new file mode 100644 index 000000000..a526ea34a --- /dev/null +++ b/pkg/tools/web_test.go @@ -0,0 +1,256 @@ +package tools + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestWebTool_WebFetch_Success verifies successful URL fetching +func TestWebTool_WebFetch_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte("

Test Page

Content here

")) + })) + defer server.Close() + + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain the fetched content + if !strings.Contains(result.ForUser, "Test Page") { + t.Errorf("Expected ForUser to contain 'Test Page', got: %s", result.ForUser) + } + + // ForLLM should contain summary + if !strings.Contains(result.ForLLM, "bytes") && !strings.Contains(result.ForLLM, "extractor") { + t.Errorf("Expected ForLLM to contain summary, got: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_JSON verifies JSON content handling +func TestWebTool_WebFetch_JSON(t *testing.T) { + testData := map[string]string{"key": "value", "number": "123"} + expectedJSON, _ := json.MarshalIndent(testData, "", " ") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(expectedJSON) + })) + defer server.Close() + + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain formatted JSON + if !strings.Contains(result.ForUser, "key") && !strings.Contains(result.ForUser, "value") { + t.Errorf("Expected ForUser to contain JSON data, got: %s", result.ForUser) + } +} + +// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL +func TestWebTool_WebFetch_InvalidURL(t *testing.T) { + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": "not-a-valid-url", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for invalid URL") + } + + // Should contain error message (either "invalid URL" or scheme error) + if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") { + t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs +func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": "ftp://example.com/file.txt", + } + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error for unsupported URL scheme") + } + + // Should mention only http/https allowed + if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { + t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL +func TestWebTool_WebFetch_MissingURL(t *testing.T) { + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when URL is missing") + } + + // Should mention URL is required + if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { + t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) + } +} + +// TestWebTool_WebFetch_Truncation verifies content truncation +func TestWebTool_WebFetch_Truncation(t *testing.T) { + longContent := strings.Repeat("x", 20000) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(longContent)) + })) + defer server.Close() + + tool := NewWebFetchTool(1000) // Limit to 1000 chars + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain truncated content (not the full 20000 chars) + resultMap := make(map[string]interface{}) + json.Unmarshal([]byte(result.ForUser), &resultMap) + if text, ok := resultMap["text"].(string); ok { + if len(text) > 1100 { // Allow some margin + t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) + } + } + + // Should be marked as truncated + if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { + t.Errorf("Expected 'truncated' to be true in result") + } +} + +// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing +func TestWebTool_WebSearch_NoApiKey(t *testing.T) { + tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) + if tool != nil { + t.Errorf("Expected nil tool when Brave API key is empty") + } + + // Also nil when nothing is enabled + tool = NewWebSearchTool(WebSearchToolOptions{}) + if tool != nil { + t.Errorf("Expected nil tool when no provider is enabled") + } +} + +// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query +func TestWebTool_WebSearch_MissingQuery(t *testing.T) { + tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) + ctx := context.Background() + args := map[string]interface{}{} + + result := tool.Execute(ctx, args) + + // Should return error result + if !result.IsError { + t.Errorf("Expected error when query is missing") + } +} + +// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction +func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`

Title

Content

`)) + })) + defer server.Close() + + tool := NewWebFetchTool(50000) + ctx := context.Background() + args := map[string]interface{}{ + "url": server.URL, + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain extracted text (without script/style tags) + if !strings.Contains(result.ForUser, "Title") && !strings.Contains(result.ForUser, "Content") { + t.Errorf("Expected ForUser to contain extracted text, got: %s", result.ForUser) + } + + // Should NOT contain script or style tags + if strings.Contains(result.ForUser, "