diff --git a/.dockerignore b/.dockerignore index d632da5ea..9ddc971ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ +# Do NOT exclude LICENSE or .github — scripts/copydir.go uses them as repo-root anchors +# during `go generate`, which runs inside `make build` in the Dockerfile. .git .gitignore build/ @@ -6,5 +8,4 @@ config/ .env .env.example *.md -LICENSE assets/ diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index d507234dc..39ad8810e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -74,10 +74,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: env.DOCKERHUB_USERNAME != '' uses: docker/login-action@v4 - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -89,10 +86,6 @@ jobs: - name: Create local tag for GoReleaser run: git tag "${{ steps.version.outputs.version }}" - - name: Lowercase owner for Docker tags - id: repo - run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: @@ -101,7 +94,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO_OWNER: ${{ steps.repo.outputs.owner }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} @@ -151,154 +144,3 @@ jobs: --prerelease \ --latest=false \ "${ASSETS[@]}" - - build-macos-launcher: - name: Build macOS Launcher (${{ matrix.arch_name }}) - runs-on: macos-latest - permissions: - contents: read - strategy: - matrix: - include: - - goarch: arm64 - arch_name: arm64 - - goarch: amd64 - arch_name: x86_64 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Go from go.mod - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 10.33.0 - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: web/frontend/pnpm-lock.yaml - - - name: Build frontend - run: | - cd web/frontend - CI=true pnpm install --frozen-lockfile - pnpm build:backend - - - name: Compute version - id: version - run: | - DATE=$(date -u +%Y%m%d) - SHA=$(git rev-parse --short=8 HEAD) - BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) - if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then - VERSION="v0.0.0-nightly.${DATE}.${SHA}" - else - VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}" - fi - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - - name: Build picoclaw-launcher with CGO - env: - CGO_ENABLED: "1" - GOOS: darwin - GOARCH: ${{ matrix.goarch }} - run: | - SDK_PATH=$(xcrun --show-sdk-path) - export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0" - export CGO_LDFLAGS="-isysroot ${SDK_PATH}" - - go generate ./... - go build -tags "goolm,stdjson" \ - -ldflags "-s -w \ - -X github.com/sipeed/picoclaw/pkg/config.Version=${{ steps.version.outputs.version }} \ - -X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \ - -X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -o picoclaw-launcher-cgo \ - ./web/backend - - - name: Sign and notarize launcher binary - if: env.MACOS_SIGN_P12 != '' - env: - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - run: | - pip3 install rcodesign - - echo "$MACOS_SIGN_P12" | base64 -d > cert.p12 - - rcodesign sign \ - --p12-file cert.p12 \ - --p12-password "$MACOS_SIGN_PASSWORD" \ - picoclaw-launcher-cgo - - echo "$MACOS_NOTARY_KEY" > notary-key.p8 - - rcodesign notary-submit \ - --api-key-path notary-key.p8 \ - --api-issuer "$MACOS_NOTARY_ISSUER_ID" \ - --wait \ - picoclaw-launcher-cgo - - rm -f cert.p12 notary-key.p8 - - - name: Upload launcher artifact - uses: actions/upload-artifact@v4 - with: - name: macos-launcher-${{ matrix.arch_name }} - path: picoclaw-launcher-cgo - retention-days: 1 - - patch-macos-archives: - name: Patch macOS Archives - needs: [nightly, build-macos-launcher] - runs-on: ubuntu-latest - permissions: - contents: write - strategy: - matrix: - include: - - arch_name: arm64 - - arch_name: x86_64 - steps: - - name: Download launcher artifact - uses: actions/download-artifact@v4 - with: - name: macos-launcher-${{ matrix.arch_name }} - - - name: Patch darwin release archive - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz" - - gh release download nightly \ - --repo "${{ github.repository }}" \ - --pattern "${ARCHIVE_NAME}" \ - --dir ./patch-tmp - - mkdir -p ./patch-extracted - tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted - - cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher - chmod +x ./patch-extracted/picoclaw-launcher - - tar czf "${ARCHIVE_NAME}" -C ./patch-extracted . - - gh release upload nightly \ - --repo "${{ github.repository }}" \ - "${ARCHIVE_NAME}" --clobber - - echo "✅ Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9aa054943..a52b6df8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,10 +80,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: env.DOCKERHUB_USERNAME != '' uses: docker/login-action@v4 - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -92,10 +89,6 @@ jobs: - name: Install zip run: sudo apt-get install -y zip - - name: Lowercase owner for Docker tags - id: repo - run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: @@ -104,7 +97,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO_OWNER: ${{ steps.repo.outputs.owner }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} INCLUDE_ANDROID_BUNDLE: "true" @@ -123,149 +116,9 @@ jobs: --draft=${{ inputs.draft }} \ --prerelease=${{ inputs.prerelease }} - build-macos-launcher: - name: Build macOS Launcher (${{ matrix.arch_name }}) - runs-on: macos-latest - permissions: - contents: read - strategy: - matrix: - include: - - goarch: arm64 - arch_name: arm64 - - goarch: amd64 - arch_name: x86_64 - steps: - - name: Checkout tag - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ inputs.tag }} - - - name: Setup Go from go.mod - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 10.33.0 - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: web/frontend/pnpm-lock.yaml - - - name: Build frontend - run: | - cd web/frontend - CI=true pnpm install --frozen-lockfile - pnpm build:backend - - - name: Build picoclaw-launcher with CGO - env: - CGO_ENABLED: "1" - GOOS: darwin - GOARCH: ${{ matrix.goarch }} - run: | - SDK_PATH=$(xcrun --show-sdk-path) - export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0" - export CGO_LDFLAGS="-isysroot ${SDK_PATH}" - - go generate ./... - go build -tags "goolm,stdjson" \ - -ldflags "-s -w \ - -X github.com/sipeed/picoclaw/pkg/config.Version=${{ inputs.tag }} \ - -X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \ - -X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - -o picoclaw-launcher-cgo \ - ./web/backend - - - name: Sign and notarize launcher binary - if: env.MACOS_SIGN_P12 != '' - env: - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - run: | - pip3 install rcodesign - - echo "$MACOS_SIGN_P12" | base64 -d > cert.p12 - - rcodesign sign \ - --p12-file cert.p12 \ - --p12-password "$MACOS_SIGN_PASSWORD" \ - picoclaw-launcher-cgo - - echo "$MACOS_NOTARY_KEY" > notary-key.p8 - - rcodesign notary-submit \ - --api-key-path notary-key.p8 \ - --api-issuer "$MACOS_NOTARY_ISSUER_ID" \ - --wait \ - picoclaw-launcher-cgo - - rm -f cert.p12 notary-key.p8 - - - name: Upload launcher artifact - uses: actions/upload-artifact@v4 - with: - name: macos-launcher-${{ matrix.arch_name }} - path: picoclaw-launcher-cgo - retention-days: 1 - - patch-macos-archives: - name: Patch macOS Archives - needs: [release, build-macos-launcher] - runs-on: ubuntu-latest - permissions: - contents: write - strategy: - matrix: - include: - - arch_name: arm64 - - arch_name: x86_64 - steps: - - name: Download launcher artifact - uses: actions/download-artifact@v4 - with: - name: macos-launcher-${{ matrix.arch_name }} - - - name: Patch darwin release archive - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ inputs.tag }} - run: | - ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz" - - gh release download "${TAG}" \ - --repo "${{ github.repository }}" \ - --pattern "${ARCHIVE_NAME}" \ - --dir ./patch-tmp - - mkdir -p ./patch-extracted - tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted - - cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher - chmod +x ./patch-extracted/picoclaw-launcher - - tar czf "${ARCHIVE_NAME}" -C ./patch-extracted . - - gh release upload "${TAG}" \ - --repo "${{ github.repository }}" \ - "${ARCHIVE_NAME}" --clobber - - echo "Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)" - upload-tos: name: Upload to TOS - needs: [release, patch-macos-archives] + needs: release if: ${{ inputs.upload_tos }} uses: ./.github/workflows/upload-tos.yml with: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..f454f5977 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,64 @@ +name: Close stale issues and PRs + +on: + schedule: + # Run daily at 03:00 JST (18:00 UTC) + - cron: "0 18 * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + + steps: + - name: Mark and close stale issues and PRs + uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # ── Issue: 7 days inactive → stale; 7 more days → close ── + days-before-issue-stale: 7 + days-before-issue-close: 7 + stale-issue-label: "stale" + stale-issue-message: > + This issue has had no activity for 7 days and has been marked as stale. + If it is still relevant, please reply or update; otherwise it will be + closed automatically in 7 days. + close-issue-message: > + This issue has been closed after 14 days of inactivity. + If it is still needed, feel free to reopen it anytime. + close-issue-reason: "not_planned" + + # ── PR: 7 days inactive → stale; 7 more days → close ── + days-before-pr-stale: 7 + days-before-pr-close: 7 + stale-pr-label: "stale" + stale-pr-message: > + This PR has had no activity for 7 days and has been marked as stale. + If you are still working on it, please push an update or leave a comment; + otherwise it will be closed automatically in 7 days. + close-pr-message: > + This PR has been closed after 14 days of inactivity. + If you would like to continue, feel free to reopen it or submit a new PR. + + # ── Protected labels (exempt from stale processing) ── + exempt-issue-labels: "pinned,keep-open,wip,do-not-close,type: roadmap" + exempt-pr-labels: "pinned,keep-open,wip,do-not-close,type: roadmap" + + # ── Exempt draft PRs ── + exempt-draft-pr: true + + # ── Remove stale label when activity resumes ── + remove-stale-when-updated: true + remove-issue-stale-when-updated: true + remove-pr-stale-when-updated: true + + # ── Scan oldest items first so old stale items are not starved ── + ascending: true + + # ── Throttle: max operations per run ── + operations-per-run: 500 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b330c60f5..fe43a0921 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -100,49 +100,6 @@ builds: - goos: netbsd goarch: arm - - id: picoclaw-launcher-tui - binary: picoclaw-launcher-tui - env: - - CGO_ENABLED=0 - tags: - - goolm - - stdjson - ldflags: - - -s -w - - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} - - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} - - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} - - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} - goos: - - linux - - windows - - darwin - - freebsd - - netbsd - goarch: - - amd64 - - arm64 - - riscv64 - - loong64 - - arm - - s390x - - mipsle - goarm: - - "6" - - "7" - gomips: - - softfloat - main: ./cmd/picoclaw-launcher-tui - ignore: - - goos: windows - goarch: arm - - goos: netbsd - goarch: s390x - - goos: netbsd - goarch: mips64 - - goos: netbsd - goarch: arm - dockers_v2: - id: picoclaw dockerfile: docker/Dockerfile.goreleaser @@ -151,8 +108,8 @@ dockers_v2: ids: - picoclaw images: - - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw" - - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}' + - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" + - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' tags: - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' @@ -166,10 +123,9 @@ dockers_v2: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui images: - - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw" - - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}' + - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" + - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' tags: - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' @@ -184,7 +140,6 @@ notarize: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui sign: certificate: "{{.Env.MACOS_SIGN_P12}}" password: "{{.Env.MACOS_SIGN_PASSWORD}}" @@ -215,7 +170,6 @@ nfpms: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui package_name: picoclaw file_name_template: >- {{ .PackageName }}_ @@ -224,7 +178,7 @@ nfpms: {{- else if eq .Arch "arm" }}armv{{ .Arm }} {{- else }}{{ .Arch }}{{ end }} vendor: picoclaw - homepage: https://github.com/{{ .Env.REPO_OWNER }}/picoclaw + homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw maintainer: picoclaw contributors description: picoclaw - a tool for managing and running tasks license: MIT diff --git a/Makefile b/Makefile index acb258370..3fa41bc24 100644 --- a/Makefile +++ b/Makefile @@ -171,6 +171,18 @@ ifeq ($(OS),Windows_NT) EXT=.exe endif +ifneq ($(strip $(GOOS)),) + PLATFORM:=$(GOOS) +endif + +ifneq ($(strip $(GOARCH)),) + ARCH:=$(GOARCH) +endif + +ifeq ($(PLATFORM),windows) + EXT=.exe +endif + BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) # Default target @@ -181,10 +193,11 @@ generate: @echo "Run generate..." ifeq ($(OS),Windows_NT) @$(POWERSHELL) "if (Test-Path -LiteralPath './$(CMD_DIR)/workspace') { Remove-Item -LiteralPath './$(CMD_DIR)/workspace' -Recurse -Force }" + @$(POWERSHELL) "$$env:GOOS=''; $$env:GOARCH=''; $(GO) generate ./..." else @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true + @GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./... endif - @$(GO) generate ./... @echo "Run generate complete" ## build: Build the picoclaw binary for current platform @@ -196,7 +209,7 @@ ifeq ($(OS),Windows_NT) @$(POWERSHELL) "Copy-Item -LiteralPath '$(BINARY_PATH)$(EXT)' -Destination '$(BUILD_DIR)/$(BINARY_NAME)$(EXT)' -Force" else @mkdir -p $(BUILD_DIR) - @GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR) + @GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)$(EXT)" @$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT) endif @@ -211,7 +224,7 @@ ifeq ($(OS),Windows_NT) @$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher$(EXT)' -Force" else @mkdir -p $(BUILD_DIR) - @GOARCH=${ARCH} $(MAKE) -C web build \ + @GOOS=$(PLATFORM) GOARCH=$(ARCH) $(MAKE) -C web build \ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \ WEB_GO='$(WEB_GO)' \ GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ @@ -223,20 +236,6 @@ endif build-launcher-frontend: @$(MAKE) -C web build-frontend -## build-launcher-tui: Build the picoclaw-launcher TUI binary -build-launcher-tui: - @echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..." -ifeq ($(OS),Windows_NT) - @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" - @$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT) ./cmd/picoclaw-launcher-tui - @$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher-tui$(EXT)' -Force" -else - @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui - @ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui -endif - @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui$(EXT)" - ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." diff --git a/README.md b/README.md index 73cc877fa..30ac67d8f 100644 --- a/README.md +++ b/README.md @@ -291,24 +291,6 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l -### 💻 TUI Launcher (Recommended for Headless / SSH) - -The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Getting started:** - -Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat! - -For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android diff --git a/assets/launcher-tui.jpg b/assets/launcher-tui.jpg deleted file mode 100644 index 659c97794..000000000 Binary files a/assets/launcher-tui.jpg and /dev/null differ diff --git a/assets/wechat.png b/assets/wechat.png index b368f75d3..18247ff82 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw-launcher-tui/README.md b/cmd/picoclaw-launcher-tui/README.md deleted file mode 100644 index a942045a5..000000000 --- a/cmd/picoclaw-launcher-tui/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Picoclaw Launcher TUI - -This directory contains the terminal-based TUI launcher for `picoclaw`. -It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment. - -## Architecture - -The TUI launcher is implemented purely in Go with no external runtime dependencies: -* **`main.go`**: Application entry point, handles initialization and main event loop -* **`ui/`**: TUI interface components built on tview + tcell framework: - - `home.go`: Main dashboard with navigation menu - - `schemes.go`: AI model scheme management - - `users.go`: User and API key management for model providers - - `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor - - `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status) - - `app.go`: Core TUI application framework and navigation logic - - `models.go`: Data structures and state management -* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system - -## Getting Started - -### Prerequisites - -* Go 1.25+ -* Terminal with 256-color support (most modern terminals are compatible) - -### Development - -Run the TUI launcher directly in development mode: - -```bash -# From project root -go run ./cmd/picoclaw-launcher-tui - -# Or from this directory -go run . -``` - -### Build - -Build the standalone TUI launcher binary: - -```bash -# From project root (recommended) -make build-launcher-tui - -# Output will be at: -# build/picoclaw-launcher-tui-- -# with symlink build/picoclaw-launcher-tui - -# Or build directly from this directory -go build -o picoclaw-launcher-tui . -``` - -### Key Features - -* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments -* ⚙️ AI model scheme and API key management -* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.) -* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring) -* 💬 One-click launch of interactive AI chat session -* 🎯 Keyboard-first design with intuitive shortcuts - -### Other Commands - -```bash -# Run with custom config file path -go run . /path/to/custom/config.json -``` diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go deleted file mode 100644 index 227b9fa3d..000000000 --- a/cmd/picoclaw-launcher-tui/config/config.go +++ /dev/null @@ -1,236 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -// Package config provides types and I/O for ~/.picoclaw/tui.toml. -package config - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/BurntSushi/toml" - - "github.com/sipeed/picoclaw/pkg/fileutil" -) - -// DefaultConfigPath returns the default path to the tui.toml config file. -func DefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - return filepath.Join(home, ".picoclaw", "tui.toml") -} - -// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml. -type TUIConfig struct { - Version string `toml:"version"` - Model Model `toml:"model"` - Provider Provider `toml:"provider"` -} - -type Model struct { - Type string `toml:"type"` // "provider" (default) | "manual" -} - -type Provider struct { - Schemes []Scheme `toml:"schemes"` - Users []User `toml:"users"` - Current ProviderCurrent `toml:"current"` -} - -type Scheme struct { - Name string `toml:"name"` // unique key - BaseURL string `toml:"baseURL"` // required - Type string `toml:"type"` // "openai-compatible" (default) | "anthropic" -} - -type User struct { - Name string `toml:"name"` - Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique - Type string `toml:"type"` // "key" (default) | "OAuth" - Key string `toml:"key"` -} - -type ProviderCurrent struct { - Scheme string `toml:"scheme"` // references Scheme.Name - User string `toml:"user"` // references User.Name where User.Scheme == Scheme - Model string `toml:"model"` // from GET /models -} - -// DefaultConfig returns a minimal valid TUIConfig. -func DefaultConfig() *TUIConfig { - return &TUIConfig{ - Version: "1.0", - Model: Model{Type: "provider"}, - Provider: Provider{ - Schemes: []Scheme{}, - Users: []User{}, - Current: ProviderCurrent{}, - }, - } -} - -// Load reads the TUI config from path. Returns a default config if the file does not exist. -func Load(path string) (*TUIConfig, error) { - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return DefaultConfig(), nil - } - if err != nil { - return nil, fmt.Errorf("failed to read config file %q: %w", path, err) - } - - cfg := DefaultConfig() - if _, err := toml.Decode(string(data), cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file %q: %w", path, err) - } - - applyDefaults(cfg) - return cfg, nil -} - -// Save writes cfg to path atomically (safe for flash / SD storage). -func Save(path string, cfg *TUIConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - var buf bytes.Buffer - enc := toml.NewEncoder(&buf) - if err := enc.Encode(cfg); err != nil { - return fmt.Errorf("failed to encode config: %w", err) - } - if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil { - return fmt.Errorf("failed to write config file %q: %w", path, err) - } - return nil -} - -func applyDefaults(cfg *TUIConfig) { - if cfg.Version == "" { - cfg.Version = "1.0" - } - if cfg.Model.Type == "" { - cfg.Model.Type = "provider" - } - for i := range cfg.Provider.Schemes { - if cfg.Provider.Schemes[i].Type == "" { - cfg.Provider.Schemes[i].Type = "openai-compatible" - } - } - for i := range cfg.Provider.Users { - if cfg.Provider.Users[i].Type == "" { - cfg.Provider.Users[i].Type = "key" - } - } -} - -// SchemeByName returns the first Scheme whose Name matches, or nil. -func (p *Provider) SchemeByName(name string) *Scheme { - for i := range p.Schemes { - if p.Schemes[i].Name == name { - return &p.Schemes[i] - } - } - return nil -} - -// UsersForScheme returns all users whose Scheme field matches schemeName. -func (p *Provider) UsersForScheme(schemeName string) []User { - var out []User - for _, u := range p.Users { - if u.Scheme == schemeName { - out = append(out, u) - } - } - return out -} - -// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json -// Adds/replaces a "tui-prefer" model entry and sets it as the default model. -// Preserves all other existing fields in the config file unchanged. -func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - mainConfigPath := filepath.Join(home, ".picoclaw", "config.json") - - var cfg map[string]any - if data, readErr := os.ReadFile(mainConfigPath); readErr == nil { - if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil { - cfg = make(map[string]any) - } - } else { - cfg = make(map[string]any) - } - - if _, ok := cfg["agents"]; !ok { - cfg["agents"] = make(map[string]any) - } - agents, ok := cfg["agents"].(map[string]any) - if ok { - if _, ok := agents["defaults"]; !ok { - agents["defaults"] = make(map[string]any) - } - defaults, ok := agents["defaults"].(map[string]any) - if ok { - defaults["model"] = "tui-prefer" - } - } - - tuiModel := map[string]any{ - "model_name": "tui-prefer", - "model": modelID, - "api_key": user.Key, - "api_base": scheme.BaseURL, - } - - modelList := []any{} - if ml, ok := cfg["model_list"].([]any); ok { - modelList = ml - } - - found := false - for i, m := range modelList { - if entry, ok := m.(map[string]any); ok { - if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" { - modelList[i] = tuiModel - found = true - break - } - } - } - if !found { - modelList = append(modelList, tuiModel) - } - cfg["model_list"] = modelList - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil { - return err - } - - return os.WriteFile(mainConfigPath, data, 0o600) -} - -func (cfg *TUIConfig) CurrentModelLabel() string { - cur := cfg.Provider.Current - if cur.Model == "" { - return "(not configured)" - } - label := cur.Scheme - if label != "" { - label += " / " - } - return label + cur.Model -} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go deleted file mode 100644 index 3cb7110c1..000000000 --- a/cmd/picoclaw-launcher-tui/main.go +++ /dev/null @@ -1,48 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" -) - -func main() { - configPath := tuicfg.DefaultConfigPath() - if len(os.Args) > 1 { - configPath = os.Args[1] - } - - configDir := filepath.Dir(configPath) - if _, err := os.Stat(configDir); os.IsNotExist(err) { - cmd := exec.Command("picoclaw", "onboard") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - } - - cfg, err := tuicfg.Load(configPath) - if err != nil { - fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) - os.Exit(1) - } - - app := ui.New(cfg, configPath) - // Bind model selection hook to sync to main config - app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) { - _ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID) - } - if err := app.Run(); err != nil { - fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go deleted file mode 100644 index a65693b01..000000000 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ /dev/null @@ -1,325 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - "sync" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -// App is the root TUI application. -type App struct { - tapp *tview.Application - pages *tview.Pages - pageStack []string - cfg *tuicfg.TUIConfig - configPath string - pageRefreshFns map[string]func() - headerModelTV *tview.TextView - modalOpen map[string]bool - - // OnModelSelected is called when a model is selected in the UI. - // Can be nil to disable. - OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) - - modelCache map[string][]modelEntry - modelCacheMu sync.RWMutex - refreshMu sync.Mutex -} - -// cacheKey returns the map key for a (scheme, user) pair. -func cacheKey(schemeName, userName string) string { - return fmt.Sprintf("%s/%s", schemeName, userName) -} - -// cachedModels returns a defensive copy of the cached model list for a user (may be nil). -func (a *App) cachedModels(schemeName, userName string) []modelEntry { - a.modelCacheMu.RLock() - defer a.modelCacheMu.RUnlock() - entries := a.modelCache[cacheKey(schemeName, userName)] - return append([]modelEntry(nil), entries...) -} - -// refreshModelCache fetches models for every user in the config concurrently. -// Serialized by refreshMu so concurrent calls don't race on the cache map. -// When all fetches complete it calls onDone via QueueUpdateDraw. -func (a *App) refreshModelCache(onDone func()) { - go func() { - a.refreshMu.Lock() - defer a.refreshMu.Unlock() - - users := a.cfg.Provider.Users - schemes := a.cfg.Provider.Schemes - - schemeURL := make(map[string]string, len(schemes)) - for _, s := range schemes { - schemeURL[s.Name] = s.BaseURL - } - - var wg sync.WaitGroup - for _, u := range users { - baseURL, ok := schemeURL[u.Scheme] - if !ok || baseURL == "" { - continue - } - if u.Key == "" { - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - a.modelCache[cacheKey(u.Scheme, u.Name)] = nil - a.modelCacheMu.Unlock() - continue - } - wg.Add(1) - bURL := baseURL - go func() { - defer wg.Done() - entries, err := fetchModels(bURL, u.Key) - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - if err != nil || len(entries) == 0 { - a.modelCache[cacheKey(u.Scheme, u.Name)] = nil - } else { - a.modelCache[cacheKey(u.Scheme, u.Name)] = entries - } - a.modelCacheMu.Unlock() - }() - } - wg.Wait() - - if onDone != nil { - a.tapp.QueueUpdateDraw(onDone) - } - }() -} - -// New creates and wires up the TUI application. -func New(cfg *tuicfg.TUIConfig, configPath string) *App { - // Cyberpunk Theme Colors - // Dark background - tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void - tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo - tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40) - - // Borders and Titles - tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta - - // Text - tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white - tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime - tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black - tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta - - a := &App{ - tapp: tview.NewApplication(), - pages: tview.NewPages(), - pageStack: []string{}, - cfg: cfg, - configPath: configPath, - pageRefreshFns: make(map[string]func()), - modalOpen: make(map[string]bool), - } - - a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - if len(a.modalOpen) > 0 { - return event - } - return a.goBack() - } - return event - }) - - a.buildPages() - return a -} - -// Run starts the TUI event loop. -func (a *App) Run() error { - return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run() -} - -func (a *App) buildPages() { - a.pages.AddPage("home", a.newHomePage(), true, true) - a.pageStack = []string{"home"} -} - -func (a *App) navigateTo(name string, page tview.Primitive) { - a.pages.RemovePage(name) - a.pages.AddPage(name, page, true, false) - a.pageStack = append(a.pageStack, name) - a.pages.SwitchToPage(name) -} - -func (a *App) goBack() *tcell.EventKey { - if len(a.pageStack) <= 1 { - return nil - } - popped := a.pageStack[len(a.pageStack)-1] - a.pageStack = a.pageStack[:len(a.pageStack)-1] - a.pages.RemovePage(popped) - prev := a.pageStack[len(a.pageStack)-1] - if fn, ok := a.pageRefreshFns[prev]; ok { - fn() - } - if prev == "home" && a.headerModelTV != nil { - a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ") - } - a.pages.SwitchToPage(prev) - return nil -} - -func (a *App) showModal(name string, primitive tview.Primitive) { - a.modalOpen[name] = true - a.pages.AddPage(name, primitive, true, true) -} - -func (a *App) hideModal(name string) { - delete(a.modalOpen, name) - a.pages.HidePage(name) - a.pages.RemovePage(name) -} - -func (a *App) save() { - if err := tuicfg.Save(a.configPath, a.cfg); err != nil { - a.showError("save failed: " + err.Error()) - } -} - -func (a *App) showError(msg string) { - modal := tview.NewModal(). - SetText(" [red::b]ERROR[-::-]\n\n" + msg). - AddButtons([]string{"OK"}). - SetDoneFunc(func(_ int, _ string) { - a.hideModal("error") - }) - // Cyberpunk Modal Style - modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White - modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red - modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White - a.showModal("error", modal) -} - -func (a *App) confirmDelete(label string, onConfirm func()) { - modal := tview.NewModal(). - SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]"). - AddButtons([]string{"Delete", "Cancel"}). - SetDoneFunc(func(_ int, buttonLabel string) { - a.hideModal("confirm-delete") - if buttonLabel == "Delete" { - onConfirm() - } - }) - // Cyberpunk Modal Style - modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White - modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger - modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White - a.showModal("confirm-delete", modal) -} - -func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive { - return tview.NewFlex(). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(form, height, 1, true). - AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true). - AddItem(tview.NewBox(), 0, 1, false) -} - -func hintBar(text string) *tview.TextView { - tv := tview.NewTextView(). - SetText(text). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan - tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo - return tv -} - -func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive { - var modelTV *tview.TextView - if pageID == "home" { - if a.headerModelTV == nil { - a.headerModelTV = tview.NewTextView() - a.headerModelTV.SetTextAlign(tview.AlignRight). - SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime - SetDynamicColors(true). - SetBackgroundColor(tcell.NewHexColor(0x050510)) - } - modelTV = a.headerModelTV - modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ") - } else { - modelTV = tview.NewTextView() - modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) - } - - headerLeft := tview.NewTextView(). - SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///"). - SetDynamicColors(true). - SetBackgroundColor(tcell.NewHexColor(0x050510)) - - header := tview.NewFlex(). - AddItem(headerLeft, 0, 1, false). - AddItem(modelTV, 0, 1, false) - - sidebar := tview.NewTextView(). - SetDynamicColors(true). - SetWrap(false) - sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - - // Cyberpunk Sidebar Styling - activePrefix := "[#39ff14::b]>> " // Neon Lime arrow - activeSuffix := "[-]" - inactivePrefix := "[#808080] " - inactiveSuffix := "[-]" - - sbText := "\n\n" // Top padding - - menuItem := func(id, label string) string { - if pageID == id { - return activePrefix + label + activeSuffix + "\n\n" - } - return inactivePrefix + label + inactiveSuffix + "\n\n" - } - - sbText += menuItem("home", "HOME") - sbText += menuItem("schemes", "SCHEMES") - sbText += menuItem("users", "USERS") - sbText += menuItem("models", "MODELS") - sbText += menuItem("channels", "CHANNELS") - sbText += menuItem("gateway", "GATEWAY") - - sidebar.SetText(sbText) - - footer := hintBar(hint) - - grid := tview.NewGrid(). - SetRows(1, 0, 1). - SetColumns(20, 0). // Slightly wider sidebar - AddItem(header, 0, 0, 1, 2, 0, 0, false). - AddItem(sidebar, 1, 0, 1, 1, 0, 0, false). - AddItem(content, 1, 1, 1, 1, 0, 0, true). - AddItem(footer, 2, 0, 1, 2, 0, 0, false) - - // Add a border around the content area if possible, or ensure content has its own border - // grid.SetBorders(false) // Grid borders usually look bad, handled by components - - return grid -} diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go deleted file mode 100644 index c976f1fcd..000000000 --- a/cmd/picoclaw-launcher-tui/ui/channels.go +++ /dev/null @@ -1,202 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "strconv" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func (a *App) newChannelsPage() tview.Primitive { - list := tview.NewList() - list.SetBorder(true). - SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) - list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)), - ) - list.SetHighlightFullLine(true) - list.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rebuild := func() { - sel := list.GetCurrentItem() - list.Clear() - - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - configPath := filepath.Join(home, ".picoclaw", "config.json") - - var cfg map[string]any - if data, err := os.ReadFile(configPath); err == nil { - _ = json.Unmarshal(data, &cfg) - } - - if chRaw, ok := cfg["channels"].(map[string]any); ok { - for name, ch := range chRaw { - chMap, ok := ch.(map[string]any) - enabled := "disabled" - if ok { - if e, ok := chMap["enabled"].(bool); ok && e { - enabled = "enabled" - } - } - list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() { - a.showChannelEditForm(configPath, name, chMap) - }) - } - } - - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) - } - } - rebuild() - - a.pageRefreshFns["channels"] = rebuild - - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - return a.goBack() - } - return event - }) - - return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ") -} - -func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) { - form := tview.NewForm() - form.SetBorder(true). - SetTitle(" [::b]EDIT CHANNEL "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - - fields := make(map[string]*tview.InputField) - var nameField *tview.InputField - - if channelName == "" { - nameField = tview.NewInputField(). - SetLabel("Channel Name"). - SetText(""). - SetFieldWidth(28) - form.AddFormItem(nameField) - } - - for k, v := range existing { - if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice { - continue - } - valStr := fmt.Sprintf("%v", v) - field := tview.NewInputField(). - SetLabel(k). - SetText(valStr). - SetFieldWidth(28) - form.AddFormItem(field) - fields[k] = field - } - - form.AddButton("SAVE", func() { - var cfg map[string]any - if data, err := os.ReadFile(configPath); err == nil { - if err := json.Unmarshal(data, &cfg); err != nil { - cfg = make(map[string]any) - } - } else { - cfg = make(map[string]any) - } - - if _, ok := cfg["channels"]; !ok { - cfg["channels"] = make(map[string]any) - } - channels, ok := cfg["channels"].(map[string]any) - if !ok { - channels = make(map[string]any) - cfg["channels"] = channels - } - - finalName := channelName - if channelName == "" { - if nameField == nil || nameField.GetText() == "" { - a.showError("Channel name is required") - return - } - finalName = nameField.GetText() - } - - updated := make(map[string]any) - if existing != nil { - for k, v := range existing { - updated[k] = v - } - } - for k, field := range fields { - val := field.GetText() - if val == "true" { - updated[k] = true - } else if val == "false" { - updated[k] = false - } else if num, err := strconv.Atoi(val); err == nil { - updated[k] = num - } else { - updated[k] = val - } - } - - if channelName != "" && finalName != channelName { - delete(channels, channelName) - } - channels[finalName] = updated - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - a.showError(fmt.Sprintf("Failed to save config: %v", err)) - return - } - if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { - a.showError(fmt.Sprintf("Failed to create config directory: %v", err)) - return - } - if err := os.WriteFile(configPath, data, 0o600); err != nil { - a.showError(fmt.Sprintf("Failed to write config: %v", err)) - return - } - - a.hideModal("channel-edit") - a.goBack() - }) - - form.AddButton("CANCEL", func() { - a.hideModal("channel-edit") - }) - - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("channel-edit") - return nil - } - return event - }) - - a.showModal("channel-edit", centeredForm(form, 4, 20)) -} diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go deleted file mode 100644 index 781204bf2..000000000 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ /dev/null @@ -1,229 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - "os/exec" - "runtime" - "strconv" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - "github.com/sipeed/picoclaw/pkg/config" - ppid "github.com/sipeed/picoclaw/pkg/pid" -) - -type gatewayStatus struct { - running bool - pid int - version string -} - -func picoHome() string { - return config.GetHome() -} - -func getGatewayStatus() gatewayStatus { - data := ppid.ReadPidFileWithCheck(picoHome()) - if data == nil { - return gatewayStatus{running: false} - } - return gatewayStatus{ - running: true, - pid: data.PID, - version: data.Version, - } -} - -func startGateway() error { - status := getGatewayStatus() - if status.running { - return fmt.Errorf("gateway is already running (PID: %d)", status.pid) - } - - var cmd *exec.Cmd - - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1") - } else { - cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &") - } - - err := cmd.Start() - if err != nil { - return err - } - - time.Sleep(1 * time.Second) - - if runtime.GOOS == "windows" { - cmd := exec.Command( - "wmic", - "process", - "where", - "name='picoclaw.exe' and commandline like '%gateway%'", - "get", - "processid", - ) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to get gateway PID: %w", err) - } - lines := strings.Split(string(output), "\n") - for _, line := range lines[1:] { - line = strings.TrimSpace(line) - if line == "" { - continue - } - _, err := strconv.Atoi(line) - if err == nil { - break - } - } - } - - status = getGatewayStatus() - if !status.running { - return fmt.Errorf("failed to start gateway") - } - return nil -} - -func stopGateway() error { - status := getGatewayStatus() - if !status.running { - return fmt.Errorf("gateway is not running") - } - - var err error - if runtime.GOOS == "windows" { - err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run() - } else { - err = exec.Command("kill", strconv.Itoa(status.pid)).Run() - } - if err != nil { - return err - } - - // Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file) - for i := 0; i < 5; i++ { - if !getGatewayStatus().running { - break - } - time.Sleep(200 * time.Millisecond) - } - - return nil -} - -func (a *App) newGatewayPage() tview.Primitive { - flex := tview.NewFlex().SetDirection(tview.FlexRow) - flex.SetBorder(true). - SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - flex.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - statusTV := tview.NewTextView(). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetText("Checking status...") - statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - var updateStatus func() - - // 使用List作为按钮,保证显示和交互正常 - buttons := tview.NewList() - buttons.SetBackgroundColor(tcell.NewHexColor(0x050510)) - buttons.SetMainTextColor(tcell.ColorWhite) - buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff)) - buttons.SetSelectedTextColor(tcell.ColorBlack) - - buttons.AddItem(" [lime]START[white] ", "", 0, func() { - if !getGatewayStatus().running { - err := startGateway() - if err != nil { - a.showError(err.Error()) - } - updateStatus() - } - }) - buttons.AddItem(" [red]STOP[white] ", "", 0, func() { - if getGatewayStatus().running { - err := stopGateway() - if err != nil { - a.showError(err.Error()) - } - updateStatus() - } - }) - - buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn) - buttonFlex. - AddItem(tview.NewBox(), 0, 1, false). - AddItem(buttons, 20, 1, true). - AddItem(tview.NewBox(), 0, 1, false) - - flex. - AddItem(tview.NewBox(), 0, 1, false). - AddItem(statusTV, 3, 1, false). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(buttonFlex, 4, 1, true). - AddItem(tview.NewBox(), 0, 1, false) - - updateStatus = func() { - status := getGatewayStatus() - if status.running { - versionInfo := "" - if status.version != "" { - versionInfo = fmt.Sprintf("\nVersion: %s", status.version) - } - statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo)) - buttons.SetItemText(0, " [gray]START[white] ", "") - buttons.SetItemText(1, " [red]STOP[white] ", "") - } else { - statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A") - buttons.SetItemText(0, " [lime]START[white] ", "") - buttons.SetItemText(1, " [gray]STOP[white] ", "") - } - } - - updateStatus() - - done := make(chan struct{}) - go func() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - a.tapp.QueueUpdateDraw(updateStatus) - case <-done: - return - } - } - }() - - originalInputCapture := flex.GetInputCapture() - flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - close(done) - return a.goBack() - } - if originalInputCapture != nil { - return originalInputCapture(event) - } - return event - }) - - a.pageRefreshFns["gateway"] = updateStatus - - return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ") -} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go deleted file mode 100644 index 74a7769cf..000000000 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ /dev/null @@ -1,70 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "os" - "os/exec" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func (a *App) newHomePage() tview.Primitive { - list := tview.NewList() - list.SetBorder(true). - SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) - list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)), - ) - list.SetHighlightFullLine(true) - list.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rebuildList := func() { - sel := list.GetCurrentItem() - list.Clear() - list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() { - a.navigateTo("schemes", a.newSchemesPage()) - }) - list.AddItem( - "CHANNELS: Configure communication channels", - "Manage Telegram/Discord/WeChat channels", - 'n', - func() { - a.navigateTo("channels", a.newChannelsPage()) - }, - ) - list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() { - a.navigateTo("gateway", a.newGatewayPage()) - }) - list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() { - a.tapp.Suspend(func() { - cmd := exec.Command("picoclaw", "agent") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - }) - }) - list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) - } - } - rebuildList() - - a.pageRefreshFns["home"] = rebuildList - - return a.buildShell( - "home", - list, - " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ", - ) -} diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go deleted file mode 100644 index 20e5f0182..000000000 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ /dev/null @@ -1,200 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -type modelsAPIResponse struct { - Data []modelEntry `json:"data"` -} - -type modelEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` -} - -func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false). - SetFixed(0, 0) - table.SetBorder(true). - SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - var modelIDs []string - - status := tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetDynamicColors(true). - SetText("[#ffff00]FETCHING MODELS...[-]") - status.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - flex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(status, 1, 0, false). - AddItem(table, 0, 1, false) - - apiKey := a.resolveKey(schemeName, userName) - - go func() { - var entries []modelEntry - var err error - if apiKey == "" { - err = fmt.Errorf("key is required") - } else { - entries, err = fetchModels(baseURL, apiKey) - } - - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - if err == nil && len(entries) > 0 { - a.modelCache[cacheKey(schemeName, userName)] = entries - } else { - a.modelCache[cacheKey(schemeName, userName)] = nil - } - a.modelCacheMu.Unlock() - - a.tapp.QueueUpdateDraw(func() { - if err != nil { - status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error())) - table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)")) - a.tapp.SetFocus(table) - return - } - if len(entries) == 0 { - status.SetText("[#ff2a2a]NO MODELS RETURNED[-]") - table.SetCell(0, 0, tview.NewTableCell(" (no models available)")) - a.tapp.SetFocus(table) - return - } - - status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries))) - for i, m := range entries { - modelIDs = append(modelIDs, m.ID) - table.SetCell(i, 0, - tview.NewTableCell(fmt.Sprintf("%3d", i+1)). - SetAlign(tview.AlignRight). - SetTextColor(tcell.NewHexColor(0x808080)). - SetSelectable(false), - ) - table.SetCell(i, 1, - tview.NewTableCell(" "+m.ID). - SetAlign(tview.AlignLeft). - SetExpansion(1). - SetTextColor(tcell.NewHexColor(0xe0e0e0)), - ) - } - a.tapp.SetFocus(table) - }) - }() - - table.SetSelectedFunc(func(row, _ int) { - if row < 0 || row >= len(modelIDs) { - return - } - a.cfg.Provider.Current = tuicfg.ProviderCurrent{ - Scheme: schemeName, - User: userName, - Model: modelIDs[row], - } - a.save() - - // Trigger model selected callback if set - if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" { - scheme := a.cfg.Provider.SchemeByName(schemeName) - if scheme == nil { - a.goBack() - return - } - var user tuicfg.User - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - user = u - break - } - } - a.OnModelSelected(*scheme, user, modelIDs[row]) - } - - a.goBack() - }) - - return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ") -} - -func (a *App) resolveKey(schemeName, userName string) string { - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - return u.Key - } - } - return "" -} - -func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { - url := strings.TrimRight(baseURL, "/") + "/models" - - client := &http.Client{Timeout: 15 * time.Second} - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("build request: %w", err) - } - if apiKey != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - var result modelsAPIResponse - if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 { - return result.Data, nil - } - - var arr []modelEntry - if err := json.Unmarshal(body, &arr); err == nil { - return arr, nil - } - - return nil, fmt.Errorf( - "decode response: unrecognized shape: %s", - strings.TrimSpace(string(body[:min(len(body), 256)])), - ) -} diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go deleted file mode 100644 index e38d7fa86..000000000 --- a/cmd/picoclaw-launcher-tui/ui/schemes.go +++ /dev/null @@ -1,252 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -func (a *App) newSchemesPage() tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false) - table.SetBorder(true). - SetTitle(" [#00f0ff::b] PROVIDER SCHEMES "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rowToIdx := func(row int) int { return row / 2 } - - selectedSchemeName := func() string { - row, _ := table.GetSelection() - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - if idx >= 0 && idx < len(schemes) { - return schemes[idx].Name - } - return "" - } - - rebuild := func() { - selName := selectedSchemeName() - table.Clear() - schemes := a.cfg.Provider.Schemes - for i, s := range schemes { - nameRow := i * 2 - detailRow := nameRow + 1 - - table.SetCell(nameRow, 0, - tview.NewTableCell(" "+s.Name). - SetTextColor(tcell.NewHexColor(0xe0e0e0)). - SetExpansion(1). - SetSelectable(true), - ) - - users := a.cfg.Provider.UsersForScheme(s.Name) - n := len(users) - m := 0 - for _, u := range users { - if models := a.cachedModels(s.Name, u.Name); len(models) > 0 { - m++ - } - } - table.SetCell(detailRow, 0, - tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)). - SetTextColor(tcell.NewHexColor(0x808080)). - SetExpansion(1). - SetSelectable(false), - ) - table.SetCell(detailRow, 1, - tview.NewTableCell("[#00f0ff]"+s.Type+" "). - SetAlign(tview.AlignRight). - SetSelectable(false), - ) - } - if selName != "" { - for i, s := range schemes { - if s.Name == selName { - table.Select(i*2, 0) - return - } - } - } - if table.GetRowCount() > 0 { - table.Select(0, 0) - } - } - rebuild() - - a.refreshModelCache(rebuild) - a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) } - - table.SetSelectedFunc(func(row, _ int) { - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - if idx < 0 || idx >= len(schemes) { - return - } - name := schemes[idx].Name - a.navigateTo("users", a.newUsersPage(name)) - }) - - table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - row, _ := table.GetSelection() - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - switch event.Rune() { - case 'a': - a.showSchemeForm(nil, func(s tuicfg.Scheme) { - a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - case 'e': - if idx < 0 || idx >= len(schemes) { - return nil - } - origName := schemes[idx].Name - orig := schemes[idx] - a.showSchemeForm(&orig, func(s tuicfg.Scheme) { - current := a.cfg.Provider.Schemes - for i, sc := range current { - if sc.Name == origName { - a.cfg.Provider.Schemes[i] = s - break - } - } - a.save() - a.refreshModelCache(func() { - rebuild() - for i, sc := range a.cfg.Provider.Schemes { - if sc.Name == s.Name { - table.Select(i*2, 0) - break - } - } - }) - }) - return nil - case 'd': - if idx < 0 || idx >= len(schemes) { - return nil - } - name := schemes[idx].Name - a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { - current := a.cfg.Provider.Schemes - newSchemes := make([]tuicfg.Scheme, 0, len(current)) - for _, sc := range current { - if sc.Name != name { - newSchemes = append(newSchemes, sc) - } - } - a.cfg.Provider.Schemes = newSchemes - - existing := a.cfg.Provider.Users - filtered := make([]tuicfg.User, 0, len(existing)) - for _, u := range existing { - if u.Scheme != name { - filtered = append(filtered, u) - } - } - a.cfg.Provider.Users = filtered - - a.save() - a.refreshModelCache(rebuild) - }) - return nil - } - return event - }) - - return a.buildShell( - "schemes", - table, - " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ", - ) -} - -func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { - name := "" - baseURL := "" - schemeType := "openai-compatible" - title := " ADD SCHEME " - - if existing != nil { - name = existing.Name - baseURL = existing.BaseURL - schemeType = existing.Type - title = " EDIT SCHEME " - } - - typeOptions := []string{"openai-compatible", "anthropic"} - typeIdx := 0 - for i, t := range typeOptions { - if t == schemeType { - typeIdx = i - break - } - } - - form := tview.NewForm() - - form. - AddInputField("Name", name, 20, nil, func(text string) { name = text }). - AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }). - AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). - AddButton("SAVE", func() { - if name == "" { - a.showError("Name is required") - return - } - if baseURL == "" { - a.showError("Base URL is required") - return - } - if existing == nil { - for _, s := range a.cfg.Provider.Schemes { - if s.Name == name { - a.showError(fmt.Sprintf("Scheme name %q already exists", name)) - return - } - } - } - a.hideModal("scheme-form") - onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType}) - }). - AddButton("CANCEL", func() { - a.hideModal("scheme-form") - }) - - form.SetBorder(true). - SetTitle(" [::b]" + title + " "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("scheme-form") - return nil - } - return event - }) - - a.showModal("scheme-form", centeredForm(form, 4, 12)) -} diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go deleted file mode 100644 index b00fc8982..000000000 --- a/cmd/picoclaw-launcher-tui/ui/users.go +++ /dev/null @@ -1,261 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -func (a *App) newUsersPage(schemeName string) tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false) - table.SetBorder(true). - SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - visibleUsers := func() []tuicfg.User { - var out []tuicfg.User - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName { - out = append(out, u) - } - } - return out - } - - findUserGlobalIdx := func(userName string) int { - for i, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - return i - } - } - return -1 - } - - rowToVisIdx := func(row int) int { return row / 2 } - - selectedUserName := func() string { - row, _ := table.GetSelection() - users := visibleUsers() - visIdx := rowToVisIdx(row) - if visIdx >= 0 && visIdx < len(users) { - return users[visIdx].Name - } - return "" - } - - rebuild := func() { - selName := selectedUserName() - table.Clear() - users := visibleUsers() - for i, u := range users { - nameRow := i * 2 - detailRow := nameRow + 1 - - table.SetCell(nameRow, 0, - tview.NewTableCell(" "+u.Name). - SetTextColor(tcell.NewHexColor(0xe0e0e0)). - SetExpansion(1). - SetSelectable(true), - ) - table.SetCell(nameRow, 1, - tview.NewTableCell(""). - SetSelectable(false), - ) - - models := a.cachedModels(schemeName, u.Name) - var detailText string - if len(models) > 0 { - detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models)) - } else { - detailText = " [#ff2a2a]Inactive / No Access[-]" - } - table.SetCell(detailRow, 0, - tview.NewTableCell(detailText). - SetTextColor(tcell.NewHexColor(0x808080)). - SetExpansion(1). - SetSelectable(false), - ) - table.SetCell(detailRow, 1, - tview.NewTableCell("[#00f0ff]"+u.Type+" "). - SetAlign(tview.AlignRight). - SetSelectable(false), - ) - } - if selName != "" { - for i, u := range users { - if u.Name == selName { - table.Select(i*2, 0) - return - } - } - } - if table.GetRowCount() > 0 { - table.Select(0, 0) - } - } - rebuild() - - a.refreshModelCache(rebuild) - a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) } - - table.SetSelectedFunc(func(row, _ int) { - visIdx := rowToVisIdx(row) - users := visibleUsers() - if visIdx < 0 || visIdx >= len(users) { - return - } - uName := users[visIdx].Name - scheme := a.cfg.Provider.SchemeByName(schemeName) - if scheme == nil { - a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) - return - } - a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) - }) - - table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - row, _ := table.GetSelection() - visIdx := rowToVisIdx(row) - users := visibleUsers() - switch event.Rune() { - case 'a': - a.showUserForm(schemeName, nil, func(u tuicfg.User) { - a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - case 'e': - if visIdx < 0 || visIdx >= len(users) { - return nil - } - origName := users[visIdx].Name - orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)] - a.showUserForm(schemeName, &orig, func(u tuicfg.User) { - cfgIdx := findUserGlobalIdx(origName) - if cfgIdx < 0 { - a.showError(fmt.Sprintf("User %q no longer exists", origName)) - return - } - a.cfg.Provider.Users[cfgIdx] = u - a.save() - a.refreshModelCache(func() { - rebuild() - for i, usr := range visibleUsers() { - if usr.Name == u.Name { - table.Select(i*2, 0) - break - } - } - }) - }) - return nil - case 'd': - if visIdx < 0 || visIdx >= len(users) { - return nil - } - uName := users[visIdx].Name - a.confirmDelete(fmt.Sprintf("user %q", uName), func() { - cfgIdx := findUserGlobalIdx(uName) - if cfgIdx < 0 { - return - } - all := a.cfg.Provider.Users - a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - } - return event - }) - - return a.buildShell( - "users", - table, - " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ", - ) -} - -func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { - name := "" - userType := "key" - key := "" - title := " ADD USER " - - if existing != nil { - name = existing.Name - userType = existing.Type - key = existing.Key - title = " EDIT USER " - } - - typeOptions := []string{"key", "OAuth"} - typeIdx := 0 - for i, t := range typeOptions { - if t == userType { - typeIdx = i - break - } - } - - form := tview.NewForm() - form. - AddInputField("Name", name, 20, nil, func(text string) { name = text }). - AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). - AddPasswordField("Key", key, 28, '*', func(text string) { key = text }). - AddButton("SAVE", func() { - if name == "" { - a.showError("Name is required") - return - } - if existing == nil { - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == name { - a.showError(fmt.Sprintf("User name %q already exists for this scheme", name)) - return - } - } - } - a.hideModal("user-form") - onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key}) - }). - AddButton("CANCEL", func() { - a.hideModal("user-form") - }) - - form.SetBorder(true). - SetTitle(" [::b]" + title + " "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("user-form") - return nil - } - return event - }) - - a.showModal("user-form", centeredForm(form, 4, 13)) -} diff --git a/cmd/picoclaw/internal/model/add.go b/cmd/picoclaw/internal/model/add.go new file mode 100644 index 000000000..b3ebba340 --- /dev/null +++ b/cmd/picoclaw/internal/model/add.go @@ -0,0 +1,200 @@ +package model + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +const defaultAliasName = "custom-prefer" + +func newAddCommand() *cobra.Command { + var ( + apiBase string + apiKey string + modelID string + alias string + modelType string + ) + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a model from an OpenAI-compatible endpoint", + Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing +GET /models, then setting it as the default model. + +If --model is omitted, the available models are listed and you can pick one +interactively. If --model is provided, the entry is written without contacting +the server. + +Sample interactive session (key shown masked): + + $ picoclaw model add \ + -b https://ark.cn-beijing.volces.com/api/v3 \ + -k 7dff****-****-****-****-********e829 + + 115 model(s) available: + 1) doubao-lite-128k-240428 (doubao-lite-128k) + 2) doubao-pro-128k-240515 (doubao-pro-128k) + ... + 48) deepseek-r1-250120 (deepseek-r1) + 78) kimi-k2-250711 (kimi-k2) + ... + 115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0) + Pick a model (number or id): 48 + ✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`, + Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-... + picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runAdd(addOptions{ + apiBase: strings.TrimSpace(apiBase), + apiKey: strings.TrimSpace(apiKey), + modelID: strings.TrimSpace(modelID), + alias: strings.TrimSpace(alias), + modelType: strings.TrimSpace(modelType), + stdin: cmd.InOrStdin(), + stdout: cmd.OutOrStdout(), + }) + }, + } + + cmd.Flags().StringVarP(&apiBase, "api-base", "b", "", + "API base URL (required), e.g. https://api.openai.com/v1") + cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)") + cmd.Flags().StringVarP(&modelID, "model", "m", "", + "Model id; when set, skips the interactive picker and the network call") + cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName, + "Local alias written to model_list and used as the default model name") + cmd.Flags().StringVar(&modelType, "type", "openai-compatible", + "Endpoint type (only 'openai-compatible' is supported today)") + _ = cmd.MarkFlagRequired("api-base") + _ = cmd.MarkFlagRequired("api-key") + + return cmd +} + +type addOptions struct { + apiBase string + apiKey string + modelID string + alias string + modelType string + stdin io.Reader + stdout io.Writer +} + +func runAdd(opt addOptions) error { + if opt.modelType != "" && opt.modelType != "openai-compatible" { + return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType) + } + if opt.alias == "" { + opt.alias = defaultAliasName + } + + selected := opt.modelID + if selected == "" { + entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey) + if err != nil { + return fmt.Errorf("fetch models: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("no models returned by %s", opt.apiBase) + } + selected, err = pickModel(opt.stdin, opt.stdout, entries) + if err != nil { + return err + } + } + + return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout) +} + +func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) { + fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries)) + for i, m := range entries { + line := m.ID + if m.Name != "" && m.Name != m.ID { + line = fmt.Sprintf("%s (%s)", m.ID, m.Name) + } + fmt.Fprintf(stdout, " %3d) %s\n", i+1, line) + } + + scanner := bufio.NewScanner(stdin) + for { + fmt.Fprint(stdout, "Pick a model (number or id): ") + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read input: %w", err) + } + return "", fmt.Errorf("no selection provided") + } + text := strings.TrimSpace(scanner.Text()) + if text == "" { + continue + } + if idx, err := strconv.Atoi(text); err == nil { + if idx < 1 || idx > len(entries) { + fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries)) + continue + } + return entries[idx-1].ID, nil + } + for _, m := range entries { + if m.ID == text { + return m.ID, nil + } + } + fmt.Fprintln(stdout, "Not a valid number or model id; try again.") + } +} + +func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error { + configPath := internal.GetConfigPath() + cfg, err := config.LoadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + secureKeys := config.SimpleSecureStrings(apiKey) + + found := false + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if m.ModelName == alias { + m.Model = modelID + m.APIBase = apiBase + m.APIKeys = secureKeys + m.Enabled = true + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: alias, + Model: modelID, + APIBase: apiBase, + APIKeys: secureKeys, + Enabled: true, + }) + } + + cfg.Agents.Defaults.ModelName = alias + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID) + return nil +} diff --git a/cmd/picoclaw/internal/model/add_test.go b/cmd/picoclaw/internal/model/add_test.go new file mode 100644 index 000000000..5da4d5e7f --- /dev/null +++ b/cmd/picoclaw/internal/model/add_test.go @@ -0,0 +1,257 @@ +package model + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestFetchOpenAIModels_DataEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/models", r.URL.Path) + assert.Equal(t, "Bearer secret", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "secret") + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "gpt-foo", entries[0].ID) + assert.Equal(t, "Foo", entries[0].Name) + assert.Equal(t, "gpt-bar", entries[1].ID) +} + +func TestFetchOpenAIModels_BareArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "secret") + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "a", entries[0].ID) + assert.Equal(t, "b", entries[1].ID) +} + +func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`)) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL+"/", "k") + require.NoError(t, err) + assert.Equal(t, "/models", gotPath) +} + +func TestFetchOpenAIModels_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL, "bad") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 401") +} + +func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "k") + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "k") + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"models":"not-supported"}`)) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL, "k") + require.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized shape") +} + +func TestFetchOpenAIModels_RequiresInputs(t *testing.T) { + _, err := fetchOpenAIModels("", "k") + require.Error(t, err) + assert.Contains(t, err.Error(), "api base") + + _, err = fetchOpenAIModels("https://example.com", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "api key") +} + +func TestPickModel_ByIndex(t *testing.T) { + entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("2\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "b", got) + assert.Contains(t, out.String(), "3 model(s) available") +} + +func TestPickModel_ByID(t *testing.T) { + entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("beta\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "beta", got) +} + +func TestPickModel_RetriesOnInvalid(t *testing.T) { + entries := []modelEntry{{ID: "x"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "x", got) + rendered := out.String() + assert.Contains(t, rendered, "Out of range") + assert.Contains(t, rendered, "Not a valid number") +} + +func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) { + initTest(t) + + out := &bytes.Buffer{} + err := runAdd(addOptions{ + apiBase: "https://invalid.invalid/v1", + apiKey: "k", + modelID: "explicit-model", + alias: "myalias", + modelType: "openai-compatible", + stdout: out, + }) + require.NoError(t, err) + assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)") + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName()) + added := findModelByName(cfg, "myalias") + require.NotNil(t, added, "expected model 'myalias' in model_list") + assert.Equal(t, "explicit-model", added.Model) + assert.Equal(t, "https://invalid.invalid/v1", added.APIBase) + assert.True(t, added.Enabled) + require.Len(t, added.APIKeys, 1) + assert.Equal(t, "k", added.APIKeys[0].String()) +} + +func findModelByName(cfg *config.Config, name string) *config.ModelConfig { + for _, m := range cfg.ModelList { + if m != nil && m.ModelName == name { + return m + } + } + return nil +} + +func TestRunAdd_FetchAndPick(t *testing.T) { + initTest(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`)) + })) + defer srv.Close() + + out := &bytes.Buffer{} + err := runAdd(addOptions{ + apiBase: srv.URL, + apiKey: "my-key", + alias: defaultAliasName, + modelType: "openai-compatible", + stdin: strings.NewReader("2\n"), + stdout: out, + }) + require.NoError(t, err) + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName()) + added := findModelByName(cfg, defaultAliasName) + require.NotNil(t, added) + assert.Equal(t, "m2", added.Model) +} + +func TestRunAdd_UpsertsExistingAlias(t *testing.T) { + initTest(t) + + first := &bytes.Buffer{} + require.NoError(t, runAdd(addOptions{ + apiBase: "https://a.example/v1", + apiKey: "k1", + modelID: "m1", + alias: "shared", + stdout: first, + })) + + second := &bytes.Buffer{} + require.NoError(t, runAdd(addOptions{ + apiBase: "https://b.example/v1", + apiKey: "k2", + modelID: "m2", + alias: "shared", + stdout: second, + })) + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + matches := 0 + for _, m := range cfg.ModelList { + if m != nil && m.ModelName == "shared" { + matches++ + } + } + assert.Equal(t, 1, matches, "alias should be updated, not duplicated") + + updated := findModelByName(cfg, "shared") + require.NotNil(t, updated) + assert.Equal(t, "m2", updated.Model) + assert.Equal(t, "https://b.example/v1", updated.APIBase) + assert.Equal(t, "k2", updated.APIKeys[0].String()) +} + +func TestRunAdd_RejectsUnsupportedType(t *testing.T) { + initTest(t) + + err := runAdd(addOptions{ + apiBase: "https://x/v1", + apiKey: "k", + modelID: "m", + alias: "a", + modelType: "anthropic", + stdout: &bytes.Buffer{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --type") +} diff --git a/cmd/picoclaw/internal/model/command.go b/cmd/picoclaw/internal/model/command.go index 330734b82..c412993a0 100644 --- a/cmd/picoclaw/internal/model/command.go +++ b/cmd/picoclaw/internal/model/command.go @@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command { If no argument is provided, shows the current default model. If a model name is provided, sets it as the default model. +To onboard a model from a custom OpenAI-compatible endpoint (fetch the +available list online and pick one), use the 'add' subcommand: + + picoclaw model add --help + Examples: picoclaw model # Show current default model picoclaw model gpt-5.2 # Set gpt-5.2 as default picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default picoclaw model local-model # Set local VLLM server as default + picoclaw model add -b URL -k KEY # Add a model from a custom endpoint Note: 'local-model' is a special value for using a local VLLM server (running at localhost:8000 by default) which does not require an API key.`, @@ -51,6 +57,8 @@ Note: 'local-model' is a special value for using a local VLLM server }, } + cmd.AddCommand(newAddCommand()) + return cmd } @@ -66,6 +74,9 @@ func showCurrentModel(cfg *config.Config) { fmt.Println("\nAvailable models in your config:") listAvailableModels(cfg) } + + fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom") + fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').") } func listAvailableModels(cfg *config.Config) { diff --git a/cmd/picoclaw/internal/model/online.go b/cmd/picoclaw/internal/model/online.go new file mode 100644 index 000000000..9b8f7811d --- /dev/null +++ b/cmd/picoclaw/internal/model/online.go @@ -0,0 +1,77 @@ +package model + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type modelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type modelsAPIResponse struct { + Data []modelEntry `json:"data"` +} + +// fetchOpenAIModels GETs /models with Bearer auth and accepts both the +// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers. +func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) { + if strings.TrimSpace(baseURL) == "" { + return nil, fmt.Errorf("api base is required") + } + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("api key is required") + } + + url := strings.TrimRight(baseURL, "/") + "/models" + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + // {"data": [...]} envelope. Distinguish "envelope shape with empty list" + // from "object without a data key" via Data being non-nil after unmarshal: + // json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves + // it as nil when "data" is absent or null. + var envelope modelsAPIResponse + if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil { + return envelope.Data, nil + } + + // Bare-array shape, including `[]`. + var arr []modelEntry + if err := json.Unmarshal(body, &arr); err == nil { + return arr, nil + } + + preview := body + if len(preview) > 256 { + preview = preview[:256] + } + return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview))) +} diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index 3f0ff0d8d..bf8f4104f 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -//go:generate go run ../../../../scripts/copydir.go "${DOLLAR}{codespace}/workspace" ./workspace +//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace //go:embed workspace var embeddedFiles embed.FS diff --git a/config/config.example.json b/config/config.example.json index 30460c231..910c4fbd3 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -11,6 +11,8 @@ "summarize_message_threshold": 20, "summarize_token_percent": 75, "split_on_marker": false, + "max_llm_retries": 2, + "llm_retry_backoff_secs": 2, "tool_feedback": { "enabled": false, "max_args_length": 300, @@ -41,6 +43,7 @@ }, { "model_name": "gemini", + "_comment": "Optional: set \"tool_schema_transform\": \"simple\" for providers that reject complex tool JSON Schema.", "model": "antigravity/gemini-2.0-flash", "auth_method": "oauth" }, @@ -437,6 +440,9 @@ "enabled": true, "mode": "bytes" }, + "serial": { + "enabled": false + }, "send_tts": { "enabled": false }, @@ -476,6 +482,15 @@ "approval_timeout_ms": 60000 } }, + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "exclude": [], + "min_severity": "info", + "include_payload": false + } + }, "gateway": { "_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.", "host": "localhost", diff --git a/docker/Dockerfile.full b/docker/Dockerfile.full index 30e1680d5..1dc0679c9 100644 --- a/docker/Dockerfile.full +++ b/docker/Dockerfile.full @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ -FROM golang:1.26.0-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make diff --git a/docker/Dockerfile.goreleaser.launcher b/docker/Dockerfile.goreleaser.launcher index 0a20a90b3..97944afc1 100644 --- a/docker/Dockerfile.goreleaser.launcher +++ b/docker/Dockerfile.goreleaser.launcher @@ -6,7 +6,6 @@ RUN apk add --no-cache ca-certificates tzdata COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher -COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui ENTRYPOINT ["picoclaw-launcher"] CMD ["-console", "-public", "-no-browser"] diff --git a/docker/Dockerfile.heavy b/docker/Dockerfile.heavy index 2a9fc742d..81f6976a2 100644 --- a/docker/Dockerfile.heavy +++ b/docker/Dockerfile.heavy @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ -FROM golang:1.26.0-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make diff --git a/docker/Dockerfile.launcher b/docker/Dockerfile.launcher new file mode 100644 index 000000000..33fdc6d6e --- /dev/null +++ b/docker/Dockerfile.launcher @@ -0,0 +1,65 @@ +# ============================================================ +# Stage 1: Build frontend assets (Node.js + pnpm) +# ============================================================ +FROM node:24-alpine3.23 AS frontend + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /src/web/frontend + +# Cache frontend dependencies +COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./ +RUN CI=true pnpm install --frozen-lockfile + +# Build frontend +COPY web/frontend/ ./ +RUN pnpm build:backend + +# ============================================================ +# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher) +# ============================================================ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git make + +WORKDIR /src + +# Cache Go dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Copy pre-built frontend assets into the backend embed directory +COPY --from=frontend /src/web/backend/dist web/backend/dist + +# Build picoclaw binary (includes go generate) +RUN make build + +# Build picoclaw-launcher binary (frontend already built in stage 1) +# Mirror ldflags from web/Makefile to inject version metadata +RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \ + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \ + GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \ + BUILD_TIME=$(date +%FT%T%z) && \ + GO_VERSION=$(go env GOVERSION) && \ + CGO_ENABLED=0 go build -v -tags goolm,stdjson \ + -ldflags "-X ${CONFIG_PKG}.Version=${VERSION} -X ${CONFIG_PKG}.GitCommit=${GIT_COMMIT} -X ${CONFIG_PKG}.BuildTime=${BUILD_TIME} -X ${CONFIG_PKG}.GoVersion=${GO_VERSION} -s -w" \ + -o build/picoclaw-launcher ./web/backend/ + +# ============================================================ +# Stage 3: Minimal runtime image +# ============================================================ +FROM alpine:3.23 + +RUN apk add --no-cache ca-certificates tzdata curl + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:18790/health || exit 1 + +COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw +COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher + +ENTRYPOINT ["picoclaw-launcher"] +CMD ["-console", "-public", "-no-browser"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7c940621f..b12959fc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,6 +4,9 @@ services: # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello" # ───────────────────────────────────────────── picoclaw-agent: + build: + context: .. + dockerfile: docker/Dockerfile image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-agent profiles: @@ -22,6 +25,9 @@ services: # docker compose -f docker/docker-compose.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: + build: + context: .. + dockerfile: docker/Dockerfile image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-gateway restart: unless-stopped @@ -38,6 +44,9 @@ services: # docker compose -f docker/docker-compose.yml --profile launcher up # ───────────────────────────────────────────── picoclaw-launcher: + build: + context: .. + dockerfile: docker/Dockerfile.launcher image: docker.io/sipeed/picoclaw:launcher container_name: picoclaw-launcher restart: unless-stopped diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 6df7447a7..e5fc3b540 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -6,6 +6,7 @@ Internal architecture notes for major runtime mechanisms and subsystem design. - [SubTurn Mechanism](subturn.md): sub-agent coordination, concurrency control, and lifecycle handling. - [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md)) - [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md)) +- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md)) - [Hook System Guide](hooks/README.md): current hook architecture and protocol details. - [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work. diff --git a/docs/architecture/hooks/README.md b/docs/architecture/hooks/README.md index 5be0f30b5..06f1a2c07 100644 --- a/docs/architecture/hooks/README.md +++ b/docs/architecture/hooks/README.md @@ -13,7 +13,7 @@ The repository no longer ships standalone example source files. The Go and Pytho | Type | Interface | Stage | Can modify data | | --- | --- | --- | --- | -| Observer | `EventObserver` | EventBus broadcast | No | +| Observer | `RuntimeEventObserver` | Runtime event bus broadcast | No | | LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes | | Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes | | Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny | @@ -136,9 +136,9 @@ Example: "/tmp/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -174,7 +174,7 @@ Both examples are intentionally safe: they only log, never rewrite, and never de The following is a minimal logging hook for in-process use. It implements: -1. `EventObserver` +1. `RuntimeEventObserver` 2. `LLMInterceptor` 3. `ToolInterceptor` 4. `ToolApprover` @@ -196,6 +196,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook { } } -func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error { +func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { _ = ctx if h == nil || !h.logEvents { return nil } - h.record("event", evt.Meta, map[string]any{ + h.record("event", evt.Scope, map[string]any{ "event": evt.Kind.String(), "payload": evt.Payload, }, nil) @@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool( return decision, nil } -func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) { +func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) { logger.InfoCF("hooks", "Example hook observed", map[string]any{ "stage": stage, }) @@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a entry := map[string]any{ "ts": time.Now().UTC(), "stage": stage, - "meta": meta, + "refs": refs, "payload": payload, "decision": decision, } @@ -428,7 +429,7 @@ If you only see `before_llm` and `after_llm`, that usually means the request did The following script is a minimal process-hook example. It uses only the Python standard library and supports: 1. `hook.hello` -2. `hook.event` +2. `hook.runtime_event` 3. `hook.before_tool` 4. `hook.approve_tool` @@ -564,8 +565,8 @@ def main() -> int: }) if not message_id: - if method == "hook.event" and LOG_EVENTS: - log_stderr(f"observed event: {params.get('Kind')}") + if method == "hook.runtime_event" and LOG_EVENTS: + log_stderr(f"observed event: {params.get('kind')}") continue try: @@ -606,9 +607,9 @@ if __name__ == "__main__": "/abs/path/to/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -626,7 +627,7 @@ if __name__ == "__main__": ### Environment Variables - `PICOCLAW_HOOK_LOG_EVENTS` - Whether to write `hook.event` summaries to `stderr`, enabled by default + Whether to write `hook.runtime_event` summaries to `stderr`, enabled by default - `PICOCLAW_HOOK_LOG_FILE` Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines @@ -645,7 +646,7 @@ Typical interpretation: - Only `hook.hello` The process started and completed the handshake, but no business hook request has arrived yet -- `hook.event` +- `hook.runtime_event` The `observe` configuration is working - `hook.before_tool` The `intercept: ["before_tool", ...]` configuration is working @@ -664,7 +665,7 @@ A complete sample: ```json {"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false} {"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null} -{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true} +{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false} {"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null} ``` @@ -672,7 +673,7 @@ A complete sample: Additional notes: - Timestamps are UTC -- `notification=true` means it was a notification such as `hook.event`, which does not expect a response +- `notification=true` means it was a notification such as `hook.runtime_event`, which does not expect a response - `id` increases within a single hook process; if the process restarts, the counter starts over ## Process-Hook Protocol @@ -681,7 +682,7 @@ Current process hooks use `JSON-RPC over stdio`: - PicoClaw starts the external process - Requests and responses are exchanged as one JSON message per line -- `hook.event` is a notification and does not need a response +- `hook.runtime_event` is a notification and does not need a response - `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages. diff --git a/docs/architecture/hooks/README.zh.md b/docs/architecture/hooks/README.zh.md index 2170d45c8..1fff40832 100644 --- a/docs/architecture/hooks/README.zh.md +++ b/docs/architecture/hooks/README.zh.md @@ -13,7 +13,7 @@ | 类型 | 接口 | 作用阶段 | 能否改写 | | --- | --- | --- | --- | -| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 | +| 观察型 | `RuntimeEventObserver` | runtime event bus 广播事件时 | 否 | | LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 | | Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 | | Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 | @@ -136,9 +136,9 @@ HookManager 的排序规则是: "/tmp/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -174,7 +174,7 @@ tail -f /tmp/picoclaw-hook-review-gate.log 下面这段代码是一个最小的“记录型” in-process hook。它实现了: -1. `EventObserver` +1. `RuntimeEventObserver` 2. `LLMInterceptor` 3. `ToolInterceptor` 4. `ToolApprover` @@ -196,6 +196,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook { } } -func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error { +func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { _ = ctx if h == nil || !h.logEvents { return nil } - h.record("event", evt.Meta, map[string]any{ + h.record("event", evt.Scope, map[string]any{ "event": evt.Kind.String(), "payload": evt.Payload, }, nil) @@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool( return decision, nil } -func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) { +func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) { logger.InfoCF("hooks", "Example hook observed", map[string]any{ "stage": stage, }) @@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a entry := map[string]any{ "ts": time.Now().UTC(), "stage": stage, - "meta": meta, + "refs": refs, "payload": payload, "decision": decision, } @@ -428,7 +429,7 @@ func init() { 下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持: 1. `hook.hello` -2. `hook.event` +2. `hook.runtime_event` 3. `hook.before_tool` 4. `hook.approve_tool` @@ -564,8 +565,8 @@ def main() -> int: }) if not message_id: - if method == "hook.event" and LOG_EVENTS: - log_stderr(f"observed event: {params.get('Kind')}") + if method == "hook.runtime_event" and LOG_EVENTS: + log_stderr(f"observed event: {params.get('kind')}") continue try: @@ -606,9 +607,9 @@ if __name__ == "__main__": "/abs/path/to/review_gate.py" ], "observe": [ - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped" + "agent.tool.exec_start", + "agent.tool.exec_end", + "agent.tool.exec_skipped" ], "intercept": [ "before_tool", @@ -626,7 +627,7 @@ if __name__ == "__main__": ### 环境变量 - `PICOCLAW_HOOK_LOG_EVENTS` - 是否把 `hook.event` 写到 `stderr`,默认开启 + 是否把 `hook.runtime_event` 写到 `stderr`,默认开启 - `PICOCLAW_HOOK_LOG_FILE` 外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件 @@ -645,7 +646,7 @@ if __name__ == "__main__": - 只看到 `hook.hello` 说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来 -- 看到 `hook.event` +- 看到 `hook.runtime_event` 说明 `observe` 配置生效了 - 看到 `hook.before_tool` 说明 `intercept: ["before_tool", ...]` 生效了 @@ -664,7 +665,7 @@ if __name__ == "__main__": ```json {"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false} {"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null} -{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true} +{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false} {"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null} ``` @@ -672,7 +673,7 @@ if __name__ == "__main__": 补充说明: - 时间戳是 UTC,不是本地时区 -- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知 +- `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知 - `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始 ## Process Hook 协议约定 @@ -681,7 +682,7 @@ if __name__ == "__main__": - PicoClaw 启动外部进程 - 请求和响应都按“一行一个 JSON 消息”传输 -- `hook.event` 是 notification,不需要响应 +- `hook.runtime_event` 是 notification,不需要响应 - `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response 当前宿主不会接受 process hook 主动发起的新 RPC。也就是说,外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。 diff --git a/docs/architecture/hooks/hook-json-protocol.md b/docs/architecture/hooks/hook-json-protocol.md index 58b6e323b..725869a02 100644 --- a/docs/architecture/hooks/hook-json-protocol.md +++ b/docs/architecture/hooks/hook-json-protocol.md @@ -437,21 +437,28 @@ Approval hook for deciding whether to allow execution of sensitive tools. --- -## 7. `hook.event` (notification) +## 7. `hook.runtime_event` (notification) -Observer event, broadcast only, no response required. `id` is `0` or absent. +Runtime observer event, broadcast only, no response required. `id` is `0` or absent. ```json { "jsonrpc": "2.0", - "method": "hook.event", + "method": "hook.runtime_event", "params": { - "Kind": "tool_exec_start", - "Meta": { - "AgentID": "agent-1", - "TurnID": "turn-1" + "kind": "agent.tool.exec_start", + "source": { + "component": "agent", + "name": "agent-1" }, - "Payload": { + "scope": { + "agent_id": "agent-1", + "session_key": "session-1", + "turn_id": "turn-1", + "channel": "cli", + "chat_id": "chat-1" + }, + "payload": { "Tool": "echo_text", "Arguments": {"text": "hello"} } @@ -460,12 +467,14 @@ Observer event, broadcast only, no response required. `id` is `0` or absent. ``` Common `Kind` values: -- `turn_start` / `turn_end` -- `llm_request` / `llm_response` -- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped` -- `steering_injected` -- `interrupt_received` -- `error` +- `agent.turn.start` / `agent.turn.end` +- `agent.llm.request` / `agent.llm.response` +- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped` +- `agent.steering.injected` +- `agent.interrupt.received` +- `agent.error` + +Legacy observe configuration names such as `turn_end` and `tool_exec_start` are still accepted and normalized to runtime event names. New process hook notifications use `hook.runtime_event`. --- @@ -565,4 +574,4 @@ def handle_before_tool(params: dict) -> dict: return {"action": "continue"} ``` -This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw. \ No newline at end of file +This way, external hooks can fully implement plugin tools without registering any tool implementation inside PicoClaw. diff --git a/docs/architecture/hooks/hook-json-protocol.zh.md b/docs/architecture/hooks/hook-json-protocol.zh.md index 675e0a429..9c11c6270 100644 --- a/docs/architecture/hooks/hook-json-protocol.zh.md +++ b/docs/architecture/hooks/hook-json-protocol.zh.md @@ -437,21 +437,28 @@ --- -## 7. `hook.event`(notification) +## 7. `hook.runtime_event`(notification) -观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。 +runtime 观察型事件,仅广播,无需响应。`id` 为 `0` 或不存在。 ```json { "jsonrpc": "2.0", - "method": "hook.event", + "method": "hook.runtime_event", "params": { - "Kind": "tool_exec_start", - "Meta": { - "AgentID": "agent-1", - "TurnID": "turn-1" + "kind": "agent.tool.exec_start", + "source": { + "component": "agent", + "name": "agent-1" }, - "Payload": { + "scope": { + "agent_id": "agent-1", + "session_key": "session-1", + "turn_id": "turn-1", + "channel": "cli", + "chat_id": "chat-1" + }, + "payload": { "Tool": "echo_text", "Arguments": {"text": "hello"} } @@ -460,12 +467,14 @@ ``` 常见 `Kind` 值: -- `turn_start` / `turn_end` -- `llm_request` / `llm_response` -- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped` -- `steering_injected` -- `interrupt_received` -- `error` +- `agent.turn.start` / `agent.turn.end` +- `agent.llm.request` / `agent.llm.response` +- `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped` +- `agent.steering.injected` +- `agent.interrupt.received` +- `agent.error` + +旧 observe 配置名如 `turn_end`、`tool_exec_start` 仍然可用,并会归一化为 runtime event 名称。新的 process hook 通知使用 `hook.runtime_event`。 --- @@ -565,4 +574,4 @@ def handle_before_tool(params: dict) -> dict: return {"action": "continue"} ``` -通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。 \ No newline at end of file +通过这种方式,外部 hook 可以完全实现插件工具,无需在 PicoClaw 内部注册任何工具实现。 diff --git a/docs/architecture/runtime-events.md b/docs/architecture/runtime-events.md new file mode 100644 index 000000000..5d625a34b --- /dev/null +++ b/docs/architecture/runtime-events.md @@ -0,0 +1,216 @@ +# Runtime Events And Event Logging + +PicoClaw runtime events are the read-only observation surface for agent, channel, gateway, message bus, and MCP activity. Publishing events and printing logs are separate responsibilities: + +- Event publishing: components publish `pkg/events.Event` values to the runtime event bus for hooks, tests, diagnostics, and future UI consumers. +- Event logging: the built-in runtime event logger subscribes to the same bus and prints only the events selected by configuration. + +This keeps runtime code focused on publishing events while log policy stays centralized. + +## Default Behavior + +By default, only `agent.*` events are printed: + +```json +{ + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "min_severity": "info", + "include_payload": false + } + } +} +``` + +This preserves the previous behavior: agent turn, LLM, tool, steering, subturn, and error events appear in logs. Channel, gateway, bus, and MCP events are still published to the runtime event bus, but they are not printed unless configured. + +## Configuration + +The configuration lives under `events.logging` in `config.json`: + +| Field | Type | Default | Description | +| ----- | ---- | ------- | ----------- | +| `enabled` | bool | `true` | Enables the built-in event logger subscription | +| `include` | string[] | `["agent.*"]` | Event kinds to print; supports exact matches, `*`, and patterns such as `agent.*` | +| `exclude` | string[] | `[]` | Event kinds to suppress after include matching | +| `min_severity` | string | `info` | Minimum severity: `debug`, `info`, `warn`, or `error` | +| `include_payload` | bool | `false` | Adds raw event payloads to log fields | + +`include_payload` is disabled by default. Agent events print safe summary fields such as `user_len`, `args_count`, and `content_len` instead of full user messages or tool arguments. Enable raw payload logging only for short-lived diagnostics in a trusted log environment. + +## Matching Rules + +`include` and `exclude` match the `Event.Kind` string: + +```json +{ + "events": { + "logging": { + "include": ["gateway.*", "channel.lifecycle.*", "agent.error"], + "exclude": ["gateway.ready"], + "min_severity": "info" + } + } +} +``` + +Common patterns: + +- `["agent.*"]`: print agent events only. +- `["*"]`: print all runtime events. +- `["gateway.*", "channel.*"]`: print gateway and channel events only. +- `exclude: ["agent.llm.delta"]`: suppress high-volume streaming delta events. +- `min_severity: "warn"`: print warn and error events only. + +## Environment Variables + +The same settings can be overridden with environment variables: + +```bash +PICOCLAW_EVENTS_LOGGING_ENABLED=true +PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*" +PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready" +PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info +PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false +``` + +`include` and `exclude` use comma-separated values. + +## Event Names And Triggers + +The table below lists the current runtime event kinds, when they are emitted, and the most useful event details. `Source`, `Scope`, and `Correlation` are shared envelope fields that may appear on every event. The "Details" column refers to useful payload fields or log summary fields. + +### Agent + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `agent.turn.start` | An agent starts processing one user or system input after the turn scope has been created. | `user_len`, `media_count`; scope usually includes `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` | +| `agent.turn.end` | A turn exits, whether it completed, errored, or was hard-aborted. | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | Before each LLM provider request. | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | Reserved for streaming LLM deltas; the kind is defined, but the current implementation has no natural emit site. | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | After the LLM provider returns a complete response. | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | Before retrying an LLM request after context, rate-limit, transient provider, or fallback handling. | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | Agent context history is compressed, for example during proactive budget checks or LLM retry handling. | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | Async session history summarization completes. | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | Before the agent executes a tool call. | `tool`, `args_count`; full arguments are not logged by default | +| `agent.tool.exec_end` | After a tool call completes, including successful results, tool errors, and async results. | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | A tool call is skipped because the tool is unavailable, arguments are invalid, or turn control logic requires skipping it. | `tool`, `reason` | +| `agent.steering.injected` | Queued steering messages are injected into the next LLM context. | `count`, `total_content_len` | +| `agent.follow_up.queued` | An async tool result is queued back into the inbound/follow-up flow. | `source_tool`, `content_len` | +| `agent.interrupt.received` | A turn accepts steering, graceful interrupt, or hard-abort input. | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | A parent turn creates a child turn/subagent. | `child_agent_id`, `label`, `parent_turn_id` | +| `agent.subturn.end` | A child turn ends. | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | A child turn result is delivered to the target channel/chat. | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | A child turn result cannot be delivered or cannot be associated back to its parent turn. | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | Agent execution reports an error. | `stage`, `error` | + +### Channel + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `channel.lifecycle.initialized` | The channel manager creates and registers a channel instance from config. | `type`; scope includes `channel` | +| `channel.lifecycle.started` | Channel `Start()` succeeds and worker goroutines have been started; added channels during hot reload also emit it. | `type` | +| `channel.lifecycle.start_failed` | Channel `Start()` fails. | `type`, `error`; severity is `error` | +| `channel.lifecycle.stopped` | Channel `Stop()` succeeds. | `type` | +| `channel.webhook.registered` | A channel webhook handler is registered on the shared HTTP mux. | `type`; scope includes `channel` | +| `channel.webhook.unregistered` | A channel webhook handler is removed from the shared HTTP mux. | `type`; scope includes `channel` | +| `channel.message.outbound_queued` | An outbound text or media message is queued into its channel worker. | `media`, `content_len`, `reply_to_message_id`; scope comes from the original inbound context | +| `channel.message.outbound_sent` | An outbound text or media message is sent successfully, or a placeholder edit handled the response. | `media`, `content_len`, `message_ids`, `reply_to_message_id` | +| `channel.message.outbound_failed` | An outbound text or media message exhausts retries or hits a permanent failure. | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity is `error` | +| `channel.rate_limited` | A channel worker is waiting for a rate-limit token and the context is canceled, interrupting this delivery. | `media`, `content_len`, `error`, `reply_to_message_id`; severity is `warn` | + +### Message Bus + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `bus.publish.failed` | Publishing inbound, outbound, media, audio, or voice-control data fails, or required context is missing. | `stream`, `error`; scope is derived from message context when possible | +| `bus.close.started` | Message bus shutdown begins. | `drained` is usually `0` | +| `bus.close.drained` | Shutdown waits for buffered messages to drain and at least one buffered message was drained. | `drained` | +| `bus.close.completed` | Message bus shutdown completes. | `drained` | + +### Gateway + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `gateway.start` | Gateway startup reaches the agent/runtime event bus/bootstrap binding point. | `duration_ms` | +| `gateway.ready` | Gateway services, channel manager, HTTP server, and other core services are ready. | `duration_ms` | +| `gateway.shutdown` | Gateway shutdown begins. | No fixed payload; envelope fields may be the only fields | +| `gateway.reload.started` | Hot reload execution starts. | `duration_ms` | +| `gateway.reload.completed` | Hot reload completes successfully. | `duration_ms` | +| `gateway.reload.failed` | Hot reload fails. | `duration_ms`, `error`; severity is `error` | + +### MCP + +| Event | Trigger | Details | +| ----- | ------- | ------- | +| `mcp.server.connecting` | The MCP manager is about to connect to a server. | `server`, `type`, `url`, `command` | +| `mcp.server.connected` | An MCP server connects and its tool list has been initialized. | `server`, `type`, `url`, `command`, `tool_count` | +| `mcp.server.failed` | An MCP server connection fails, or the manager is closed before connecting. | `server`, `type`, `url`, `command`, `error`; severity is `error` | +| `mcp.tool.discovered` | A tool from an MCP server is discovered and registered. | `server`, `type`, `url`, `command`, `tool` | +| `mcp.tool.call.start` | The MCP tool wrapper starts a remote tool call. | `server`, `tool`; when emitted inside an agent turn, scope includes turn/chat information | +| `mcp.tool.call.end` | The MCP tool wrapper finishes a remote tool call, including failures. | `server`, `tool`, `duration_ms`, `is_error`, `error` | + +## Log Fields + +Runtime event logs include stable envelope fields when available: + +- `event_id` +- `event_kind` +- `severity` +- `event_time` +- `source_component` +- `source_name` +- `agent_id` +- `session_key` +- `turn_id` +- `channel` +- `account` +- `chat_id` +- `topic_id` +- `space_id` +- `space_type` +- `chat_type` +- `sender_id` +- `message_id` +- `trace_id` +- `parent_turn_id` +- `request_id` +- `reply_to_id` + +Agent events add safe payload summaries: + +| Event | Summary fields | +| ----- | -------------- | +| `agent.turn.start` | `user_len`, `media_count` | +| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | `tool`, `args_count` | +| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | `tool`, `reason` | +| `agent.steering.injected` | `count`, `total_content_len` | +| `agent.follow_up.queued` | `source_tool`, `content_len` | +| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | `child_agent_id`, `label` | +| `agent.subturn.end` | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | `stage`, `error` | + +## Event Domains + +Runtime event kinds are defined in `pkg/events/kind.go`. Event logging can select these domains: + +- `agent.*`: agent turn, LLM, tool, context, steering, interrupt, subturn, and error events. +- `channel.*`: channel lifecycle, webhook registration, outbound queued/sent/failed, and rate limiting. +- `bus.*`: publish failures and close lifecycle. +- `gateway.*`: start, ready, shutdown, and reload lifecycle. +- `mcp.*`: MCP server connection, tool discovery, and tool call events. + +See [`../../config/config.example.json`](../../config/config.example.json) for the default event logging example. diff --git a/docs/architecture/runtime-events.zh.md b/docs/architecture/runtime-events.zh.md new file mode 100644 index 000000000..3ed384537 --- /dev/null +++ b/docs/architecture/runtime-events.zh.md @@ -0,0 +1,216 @@ +# Runtime Events 与事件日志 + +PicoClaw 的 runtime event 是运行时观察面,用来描述 agent、channel、gateway、message bus、MCP 等组件发生了什么。事件发布和日志打印是两件事: + +- 事件发布:组件把 `pkg/events.Event` 发布到 runtime event bus,供 hook、测试、调试工具或后续 UI 消费。 +- 事件日志:内置 runtime event logger 订阅同一个 bus,并按配置把匹配的事件打印到日志。 + +这样可以让业务流程继续只负责发布事件,日志策略统一收口到一个地方。 + +## 默认行为 + +默认配置只打印 `agent.*` 事件: + +```json +{ + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "min_severity": "info", + "include_payload": false + } + } +} +``` + +这个默认值保持了旧行为:agent turn、LLM、tool、steering、subturn、error 等事件会出现在日志中;channel、gateway、bus、MCP 事件仍会发布到 runtime event bus,但默认不打印,避免网关启动和消息投递日志过于嘈杂。 + +## 配置项 + +配置位于 `config.json` 的 `events.logging`: + +| 字段 | 类型 | 默认值 | 说明 | +| ---- | ---- | ------ | ---- | +| `enabled` | bool | `true` | 是否启用内置事件日志订阅器 | +| `include` | string[] | `["agent.*"]` | 允许打印的事件 kind,支持精确匹配、`*`、`agent.*` 这类 glob/prefix | +| `exclude` | string[] | `[]` | 在 include 命中后排除的事件 kind,匹配规则同 include | +| `min_severity` | string | `info` | 最低打印级别:`debug`、`info`、`warn`、`error` | +| `include_payload` | bool | `false` | 是否把原始 payload 放进日志字段 | + +`include_payload` 默认关闭。agent 事件日志会输出安全摘要字段,例如 `user_len`、`args_count`、`content_len`,不会默认输出完整用户消息或工具参数。只有在排查问题、并且确认日志存储环境可信时,才建议临时打开 `include_payload`。 + +## 匹配规则 + +`include` 和 `exclude` 都匹配 `Event.Kind` 字符串: + +```json +{ + "events": { + "logging": { + "include": ["gateway.*", "channel.lifecycle.*", "agent.error"], + "exclude": ["gateway.ready"], + "min_severity": "info" + } + } +} +``` + +常用写法: + +- `["agent.*"]`:只打印 agent 事件。 +- `["*"]`:打印所有 runtime events。 +- `["gateway.*", "channel.*"]`:只打印 gateway 和 channel 事件。 +- `exclude: ["agent.llm.delta"]`:排除高频流式 delta 事件。 +- `min_severity: "warn"`:只打印 warn/error 事件。 + +## 环境变量 + +同一组配置也可以通过环境变量覆盖,适合临时调试: + +```bash +PICOCLAW_EVENTS_LOGGING_ENABLED=true +PICOCLAW_EVENTS_LOGGING_INCLUDE="gateway.*,channel.lifecycle.*" +PICOCLAW_EVENTS_LOGGING_EXCLUDE="gateway.ready" +PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY=info +PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD=false +``` + +`include` 和 `exclude` 的环境变量使用逗号分隔。 + +## 事件名称与触发时机 + +下面列出当前 runtime event kind、触发时机和主要事件详情。`Source`、`Scope`、`Correlation` 是所有事件都可能携带的 envelope 字段;表里的“主要详情”指 payload 或日志摘要中最有用的字段。 + +### Agent + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `agent.turn.start` | agent 开始处理一次用户输入或系统输入,turn scope 已创建时 | `user_len`, `media_count`; scope 通常包含 `agent_id`, `session_key`, `turn_id`, `channel`, `chat_id`, `message_id` | +| `agent.turn.end` | 一次 turn 退出时,无论完成、报错还是 hard abort | `status` (`completed`/`error`/`aborted`), `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | 每次调用 LLM provider 前 | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | 预留给流式 LLM delta;当前实现已定义但没有自然发送点 | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | LLM provider 返回完整响应后 | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | LLM 请求因上下文、限流、临时错误等原因准备重试前 | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | 上下文历史被压缩时,例如主动预算检查或 LLM retry 处理 | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | 会话历史异步摘要完成时 | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | agent 准备执行一个工具调用前 | `tool`, `args_count`; 默认不打印完整参数 | +| `agent.tool.exec_end` | 工具调用完成后,包括成功、工具错误和 async 结果 | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | 工具调用被跳过时,例如工具不可用、参数无效或 turn 控制逻辑要求跳过 | `tool`, `reason` | +| `agent.steering.injected` | queued steering message 被注入下一轮 LLM 上下文时 | `count`, `total_content_len` | +| `agent.follow_up.queued` | async 工具结果被重新排入 inbound/follow-up 流程时 | `source_tool`, `content_len` | +| `agent.interrupt.received` | turn 接受 steering、graceful interrupt 或 hard abort 指令时 | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | 父 turn 创建子 turn/subagent 时 | `child_agent_id`, `label`, `parent_turn_id` | +| `agent.subturn.end` | 子 turn 结束时 | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | 子 turn 结果成功投递到目标 channel/chat 时 | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | 子 turn 结果无法投递或无法关联回父 turn 时 | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | agent 执行流程报告错误时 | `stage`, `error` | + +### Channel + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `channel.lifecycle.initialized` | channel manager 根据配置创建并注册 channel 实例后 | `type`; scope 包含 `channel` | +| `channel.lifecycle.started` | channel `Start()` 成功,worker 已启动时;热重载新增 channel 也会触发 | `type` | +| `channel.lifecycle.start_failed` | channel `Start()` 失败时 | `type`, `error`; severity 为 `error` | +| `channel.lifecycle.stopped` | channel `Stop()` 成功后 | `type` | +| `channel.webhook.registered` | channel 的 webhook handler 被注册到共享 HTTP mux 时 | `type`; scope 包含 `channel` | +| `channel.webhook.unregistered` | channel 的 webhook handler 从共享 HTTP mux 移除时 | `type`; scope 包含 `channel` | +| `channel.message.outbound_queued` | outbound 文本或媒体消息被放入对应 channel worker 队列时 | `media`, `content_len`, `reply_to_message_id`; scope 来自原 inbound context | +| `channel.message.outbound_sent` | outbound 文本或媒体消息成功发送,或 placeholder edit 已处理响应时 | `media`, `content_len`, `message_ids`, `reply_to_message_id` | +| `channel.message.outbound_failed` | outbound 文本或媒体消息重试耗尽或遇到永久失败时 | `media`, `content_len`, `retries`, `error`, `reply_to_message_id`; severity 为 `error` | +| `channel.rate_limited` | channel worker 等待 rate limiter token 时被 context 取消,导致本次发送被限流/中断 | `media`, `content_len`, `error`, `reply_to_message_id`; severity 为 `warn` | + +### Message Bus + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `bus.publish.failed` | inbound、outbound、media、audio 或 voice control 发布失败,或缺少必要 context 时 | `stream`, `error`; scope 尽量来自消息 context | +| `bus.close.started` | message bus 开始关闭时 | `drained` 通常为 `0` | +| `bus.close.drained` | close 期间等待队列 drain,并且 drain 到至少一条 buffered message 时 | `drained` | +| `bus.close.completed` | message bus 完成关闭时 | `drained` | + +### Gateway + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `gateway.start` | gateway 完成 agent/runtime event bus/bootstrap 绑定后 | `duration_ms` | +| `gateway.ready` | gateway 服务、channel manager、HTTP 等关键服务启动完成后 | `duration_ms` | +| `gateway.shutdown` | gateway 开始关闭流程时 | 无固定 payload,可能只有 envelope 字段 | +| `gateway.reload.started` | 热重载开始执行时 | `duration_ms` | +| `gateway.reload.completed` | 热重载成功完成时 | `duration_ms` | +| `gateway.reload.failed` | 热重载失败时 | `duration_ms`, `error`; severity 为 `error` | + +### MCP + +| 事件名 | 触发时机 | 主要详情 | +| ------ | -------- | -------- | +| `mcp.server.connecting` | MCP manager 准备连接某个 server 前 | `server`, `type`, `url`, `command` | +| `mcp.server.connected` | MCP server 连接成功并完成工具列表初始化后 | `server`, `type`, `url`, `command`, `tool_count` | +| `mcp.server.failed` | MCP server 连接失败,或 manager 已关闭导致无法连接时 | `server`, `type`, `url`, `command`, `error`; severity 为 `error` | +| `mcp.tool.discovered` | MCP server 的某个工具被发现并注册时 | `server`, `type`, `url`, `command`, `tool` | +| `mcp.tool.call.start` | MCP tool wrapper 开始执行一次远端工具调用前 | `server`, `tool`; 如果在 agent turn 内触发,scope 会带上对应 turn/chat 信息 | +| `mcp.tool.call.end` | MCP tool wrapper 完成一次远端工具调用后,包括失败结果 | `server`, `tool`, `duration_ms`, `is_error`, `error` | + +## 日志字段 + +所有事件日志都会尽量包含稳定 envelope 字段: + +- `event_id` +- `event_kind` +- `severity` +- `event_time` +- `source_component` +- `source_name` +- `agent_id` +- `session_key` +- `turn_id` +- `channel` +- `account` +- `chat_id` +- `topic_id` +- `space_id` +- `space_type` +- `chat_type` +- `sender_id` +- `message_id` +- `trace_id` +- `parent_turn_id` +- `request_id` +- `reply_to_id` + +agent 事件还会追加 payload 摘要字段: + +| 事件 | 摘要字段 | +| ---- | -------- | +| `agent.turn.start` | `user_len`, `media_count` | +| `agent.turn.end` | `status`, `iterations_total`, `duration_ms`, `final_len` | +| `agent.llm.request` | `model`, `messages`, `tools`, `max_tokens` | +| `agent.llm.delta` | `content_delta_len`, `reasoning_delta_len` | +| `agent.llm.response` | `content_len`, `tool_calls`, `has_reasoning` | +| `agent.llm.retry` | `attempt`, `max_retries`, `reason`, `error`, `backoff_ms` | +| `agent.context.compress` | `reason`, `dropped_messages`, `remaining_messages` | +| `agent.session.summarize` | `summarized_messages`, `kept_messages`, `summary_len`, `omitted_oversized` | +| `agent.tool.exec_start` | `tool`, `args_count` | +| `agent.tool.exec_end` | `tool`, `duration_ms`, `for_llm_len`, `for_user_len`, `is_error`, `async` | +| `agent.tool.exec_skipped` | `tool`, `reason` | +| `agent.steering.injected` | `count`, `total_content_len` | +| `agent.follow_up.queued` | `source_tool`, `content_len` | +| `agent.interrupt.received` | `interrupt_kind`, `role`, `content_len`, `queue_depth`, `hint_len` | +| `agent.subturn.spawn` | `child_agent_id`, `label` | +| `agent.subturn.end` | `child_agent_id`, `status` | +| `agent.subturn.result_delivered` | `target_channel`, `target_chat_id`, `content_len` | +| `agent.subturn.orphan` | `parent_turn_id`, `child_turn_id`, `reason` | +| `agent.error` | `stage`, `error` | + +## 可打印的事件域 + +当前 runtime event kind 定义在 `pkg/events/kind.go`。事件日志配置可以选择这些域: + +- `agent.*`:agent turn、LLM、tool、context、steering、interrupt、subturn、error。 +- `channel.*`:channel lifecycle、webhook 注册、outbound queued/sent/failed、rate limited。 +- `bus.*`:publish failed、close started/drained/completed。 +- `gateway.*`:start、ready、shutdown、reload started/completed/failed。 +- `mcp.*`:server connecting/connected/failed、tool discovered、tool call start/end。 + +默认事件日志示例见 [`../../config/config.example.json`](../../config/config.example.json)。 diff --git a/docs/architecture/subturn.md b/docs/architecture/subturn.md index 0a927b56d..31a56902c 100644 --- a/docs/architecture/subturn.md +++ b/docs/architecture/subturn.md @@ -135,16 +135,16 @@ The agent loop polls for async SubTurn results at two points per iteration: All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns. -## Event Bus Integration +## Runtime Event Integration -SubTurns emit specific events to the PicoClaw `EventBus` for observability and debugging: +SubTurns emit runtime events through `pkg/events` for observability and debugging: | Event Kind | When Emitted | Payload | |:------|:-------------|:--------| -| `subturn_spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` | -| `subturn_end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` | -| `subturn_result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` | -| `subturn_orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` | +| `agent.subturn.spawn` | Sub-turn successfully initialized | `SubTurnSpawnPayload{AgentID, Label, ParentTurnID}` | +| `agent.subturn.end` | Sub-turn finishes (success or error) | `SubTurnEndPayload{AgentID, Status}` | +| `agent.subturn.result_delivered` | Async result successfully delivered to parent | `SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}` | +| `agent.subturn.orphan` | Result cannot be delivered (parent finished or channel full) | `SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}` | ## API Reference @@ -240,13 +240,13 @@ An orphan result occurs when: 2. The `pendingResults` channel is full (buffer size: 16) When a result becomes orphan: -- `SubTurnOrphanResultEvent` is emitted to EventBus +- `agent.subturn.orphan` is emitted to the runtime event bus - The result is **NOT** delivered to the LLM context - External systems can listen to this event for custom handling ### Preventing Orphan Results - Use `Critical: true` for important SubTurns that must complete -- Monitor `SubTurnOrphanResultEvent` for observability +- Monitor `agent.subturn.orphan` for observability - Consider the 16-buffer limit when spawning many async SubTurns ## Tool Inheritance diff --git a/docs/design/current-hardware-support-and-serial.zh.md b/docs/design/current-hardware-support-and-serial.zh.md new file mode 100644 index 000000000..bf91355c2 --- /dev/null +++ b/docs/design/current-hardware-support-and-serial.zh.md @@ -0,0 +1,91 @@ +# 当前硬件支持现状与串口 Tool 方案 + +## 现状结论 + +当前项目已有的硬件相关能力主要分为两条线: + +1. 设备事件监控 + - `pkg/devices` 已实现设备事件服务。 + - 当前只有 Linux USB 热插拔事件源 `pkg/devices/sources/usb_linux.go`。 + - 能力定位是“发现和通知”,不是“总线读写控制”。 + +2. 硬件控制 Tool + - `pkg/tools/hardware/i2c*.go`:I2C Tool,支持 `detect`、`scan`、`read`、`write`。 + - `pkg/tools/hardware/spi*.go`:SPI Tool,支持 `list`、`transfer`、`read`。 + - 这两类 Tool 当前都只在 Linux 主机上启用,直接依赖 `/dev/i2c-*` 与 `/dev/spidev*`。 + +因此,项目在“硬件支持能力”上已经具备: + +- Linux USB 设备插拔感知 +- Linux I2C 总线控制 +- Linux SPI 总线控制 + +但还缺少: + +- 串口/UART 控制 +- macOS / Windows 下可直接使用的硬件控制 Tool +- 面向统一硬件抽象的跨总线能力模型 + +## 本次新增 + +本次新增内建 `serial` Tool,并接入现有 Tool 体系: + +- 配置项:`tools.serial.enabled` +- Tool 注册:`pkg/agent/agent_init.go` +- Web 工具页:`/api/tools` 能展示与切换 `serial` +- 前端状态文案:新增 `requires_serial_platform` + +## Serial Tool 设计 + +`serial` 采用无状态调用模型,每次请求都自行打开和关闭端口,避免在 agent 回合之间维护串口会话状态。 + +支持动作: + +- `list`:枚举主机串口 +- `read`:从串口读取指定长度字节 +- `write`:向串口写入字节或文本 + +公共参数: + +- `port` +- `baud` +- `data_bits` +- `parity` +- `stop_bits` +- `timeout_ms` + +当前波特率实现边界: + +- Windows 允许配置工具层接受的范围 `50-4000000` +- Linux / macOS 当前仅支持标准 termios 波特率,实际支持到 `230400` +- 因此 `baud` 的跨平台可移植取值应优先使用 `230400` 及以下的常见标准速率 + +安全约束: + +- `write` 必须显式传 `confirm: true` +- 单次读写负载限制为 `4096` 字节 +- `port` 只接受白名单串口名: + - Linux / macOS 仅允许 `/dev/tty*`、`/dev/cu.*` 及对应简写设备名 + - Windows 仅允许 `COM\d+` 或 `\\.\COM\d+` + - 明确拒绝 `..`、普通文件绝对路径、盘符路径等非串口设备路径,避免路径穿越或误打开任意文件 + +## 跨平台实现边界 + +- Linux / macOS: + - 基于 `golang.org/x/sys/unix` 和 termios 配置串口参数。 + - 当前仅接入标准 termios 波特率映射,最高到 `230400`,尚未扩展 `460800`、`921600`、`1000000`、`2000000` 等更高速率。 + - 通过 `/dev/...` 枚举和访问设备。 + +- Windows: + - 基于 `kernel32` 串口 API 配置 `DCB` 和 `COMMTIMEOUTS`。 + - 当前读写仍使用同步 `ReadFile` / `WriteFile`;一旦 syscall 已进入执行,turn context cancellation 不能立即打断,只能等待 `COMMTIMEOUTS` 触发后返回。 + - 通过注册表 `HARDWARE\\DEVICEMAP\\SERIALCOMM` 枚举端口。 + +- 其他平台: + - `serial` Tool 显式返回 unsupported,不做静默降级。 + +## 后续建议 + +1. 如果需要持续交互式串口会话,建议再增加 session 型 Tool,而不是让 LLM 反复做短连接轮询。 +2. 如果后续要支持 CAN、GPIO、PWM,建议抽出统一的硬件 capability 描述层,而不是继续只靠 Tool 名称区分。 +3. 若需要生产级稳定性,建议补真实串口回环测试,至少覆盖 Linux PTY 和 Windows COM 模拟场景。 diff --git a/docs/design/hook-system-design.zh.md b/docs/design/hook-system-design.zh.md index ab5566bec..090437c20 100644 --- a/docs/design/hook-system-design.zh.md +++ b/docs/design/hook-system-design.zh.md @@ -1,11 +1,15 @@ # PicoClaw Hook 系统设计(基于 `refactor/agent`) +> 当前状态:本文是 hook 系统的早期设计记录。事件系统升级后,观察型 hook 的主路径已经切到 +> `pkg/events.Event`、`RuntimeEventObserver` 和进程 hook 的 `hook.runtime_event`。 +> 旧 `agent.Event`、`EventKind`、`hook.event` 兼容层已经删除。 + ## 背景 本设计围绕两个议题展开: - `#1316`:把 agent loop 重构为事件驱动、可中断、可追加、可观测 -- `#1796`:在 EventBus 稳定后,把 hooks 设计为 EventBus 的 consumer,而不是重新发明一套事件模型 +- `#1796`:在 runtime event bus 稳定后,把 hooks 设计为事件 consumer,而不是重新发明一套事件模型 当前分支已经完成了第一步里的“事件系统基础”,但还没有真正的 hook 挂载层。因此这里的目标不是重新设计 event,而是在已有实现上补出一层可扩展、可拦截、可外挂的 HookManager。 @@ -52,20 +56,18 @@ pi-mono 的核心思路更接近当前分支: 当前分支已经具备 hook 系统的地基: -- `pkg/agent/events.go` 定义了稳定的 `EventKind`、`EventMeta` 和 payload -- `pkg/agent/eventbus.go` 提供了非阻塞 fan-out 的 `EventBus` +- `pkg/events` 定义 runtime event envelope、kind、scope、source、severity 和 fan-out bus +- `pkg/agent/event_payloads.go` 保留 agent domain payload +- agent domain payload 保留在 `pkg/agent/event_payloads.go` - `pkg/agent/loop.go` 中的 `runTurn()` 已在 turn、llm、tool、interrupt、follow-up、summary 等节点发射事件 - `pkg/agent/steering.go` 已支持 steering、graceful interrupt、hard abort - `pkg/agent/turn.go` 已维护 turn phase、恢复点、active turn、abort 状态 ### 现有缺口 -当前分支还缺四件事: - -- 没有 HookManager,只有 EventBus -- 没有 Before/After LLM、Before/After Tool 这种同步拦截点 -- 没有审批型 hook -- 子 agent 仍走 `pkg/tools/SubagentManager + RunToolLoop`,没有接入 `pkg/agent` 的 turn tree 和事件流 +早期设计时的缺口包括 HookManager、Before/After LLM、Before/After Tool、审批型 hook +以及 sub-turn 接入。当前实现已经覆盖主 turn 的 HookManager、LLM/Tool 拦截和审批; +sub-turn 事件已接入 runtime event bus。 ### 一个关键现实 @@ -73,19 +75,19 @@ pi-mono 的核心思路更接近当前分支: ## 设计原则 -- Hook 必须建立在 `pkg/agent` 的 EventBus 和 turn 上下文之上 -- EventBus 负责广播,HookManager 负责拦截,两者职责分离 +- Hook 必须建立在 `pkg/events` runtime event bus 和 turn 上下文之上 +- runtime event bus 负责广播,HookManager 负责拦截,两者职责分离 - 项目内挂载要简单,项目外挂载必须走 IPC - 观察型 hook 不能阻塞 loop;拦截型 hook 必须有超时 - 先覆盖主 turn,不把 sub-turn 一次做满 -- 不新增第二套用户事件命名系统,优先复用 `EventKind.String()` +- 不新增第二套用户事件命名系统,新观察点统一使用 `pkg/events.Kind` ## 总体架构 分成三层: -1. `EventBus` - 负责广播只读事件,现有实现直接复用 +1. `pkg/events` runtime event bus + 负责广播只读事件,覆盖 agent、channel、gateway、bus、MCP 等运行时组件 2. `HookManager` 负责管理 hook、排序、超时、错误隔离,并在 `runTurn()` 的明确检查点执行同步拦截 @@ -97,7 +99,7 @@ pi-mono 的核心思路更接近当前分支: 换句话说: -- EventBus 是“发生了什么” +- runtime event bus 是“发生了什么” - HookManager 是“谁能介入” - HookMount 是“这些 hook 从哪里来” @@ -113,11 +115,11 @@ pi-mono 的核心思路更接近当前分支: ```go type EventObserver interface { - OnEvent(ctx context.Context, evt agent.Event) error + OnRuntimeEvent(ctx context.Context, evt events.Event) error } ``` -这类 hook 直接订阅 EventBus 即可。 +这类 hook 直接订阅 runtime event bus 即可。 适用场景: @@ -156,7 +158,7 @@ type ToolApprover interface { ## 对外暴露的最小 hook 面 -V1 不需要把所有 EventKind 都变成可拦截点。 +V1 不需要把所有 runtime event kind 都变成可拦截点。 建议只开放这些同步 hook: @@ -168,19 +170,19 @@ V1 不需要把所有 EventKind 都变成可拦截点。 其余节点继续作为只读事件暴露: -- `turn_start` -- `turn_end` -- `llm_request` -- `llm_response` -- `tool_exec_start` -- `tool_exec_end` -- `tool_exec_skipped` -- `steering_injected` -- `follow_up_queued` -- `interrupt_received` -- `context_compress` -- `session_summarize` -- `error` +- `agent.turn.start` +- `agent.turn.end` +- `agent.llm.request` +- `agent.llm.response` +- `agent.tool.exec_start` +- `agent.tool.exec_end` +- `agent.tool.exec_skipped` +- `agent.steering.injected` +- `agent.follow_up.queued` +- `agent.interrupt.received` +- `agent.context.compress` +- `agent.session.summarize` +- `agent.error` `subturn_*` 在 V1 中保留名字,但不承诺一定触发,直到子 turn 迁移完成。 @@ -369,7 +371,7 @@ PicoClaw 启动外部进程,并在其 stdin/stdout 上跑协议。 ### 观察链路 ```text -runTurn() -> emitEvent() -> EventBus -> observers +runTurn() -> emitEvent() -> runtime event bus -> observers ``` ### 拦截链路 @@ -453,7 +455,7 @@ V1 不做复杂自动发现。 ### Phase 3 - 把 `SubagentManager` 迁移到 `runTurn/sub-turn` -- 接通 `subturn_spawn` / `subturn_end` / `subturn_result_delivered` +- 接通 `agent.subturn.spawn` / `agent.subturn.end` / `agent.subturn.result_delivered` ### Phase 4 @@ -464,13 +466,13 @@ V1 不做复杂自动发现。 最适合 PicoClaw 当前分支的方案,不是直接复制 OpenClaw 的 hooks,也不是完整照搬 pi-mono 的 extension system,而是: -- 以现有 `EventBus` 为只读观察面 +- 以 `pkg/events` runtime event bus 为只读观察面 - 以新增 `HookManager` 为同步拦截面 - 项目内通过 Go 对象直接挂载 - 项目外通过 `stdio JSON-RPC` 进程通信挂载 这样做有三个好处: -- 和 `#1796` 一致,hooks 只是 EventBus 之上的消费层 +- 和 `#1796` 一致,hooks 只是 runtime event bus 之上的消费层 - 和当前 `refactor/agent` 实现一致,不需要推翻已有事件系统 - 同时满足“仓内简单挂载”和“仓外进程通信挂载”两个硬需求 diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md index dbc853d98..c41c3dae0 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -753,6 +753,42 @@ PicoClaw 按协议族路由提供商: +### 事件日志 + +PicoClaw 的 runtime events 会覆盖 agent、channel、gateway、message bus 和 MCP 等运行时组件。默认只打印 `agent.*` 事件,其他事件仍会发布到 runtime event bus,但不会进入日志。 + +```json +{ + "events": { + "logging": { + "enabled": true, + "include": ["agent.*"], + "exclude": [], + "min_severity": "info", + "include_payload": false + } + } +} +``` + +常用配置: + +```json +{ + "events": { + "logging": { + "include": ["*"], + "exclude": ["agent.llm.delta"], + "min_severity": "warn" + } + } +} +``` + +`include` / `exclude` 支持精确事件名和 `gateway.*`、`channel.lifecycle.*` 这类模式。`include_payload` 默认关闭,避免把完整用户消息或工具参数写入日志;agent 事件会默认输出长度、计数、状态等摘要字段。 + +更多字段说明和示例见 [Runtime Events 与事件日志](../architecture/runtime-events.zh.md)。 + ### 定时任务 / 提醒 PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设置、列出和取消在指定时间触发的提醒或周期性任务。 @@ -775,6 +811,7 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设 | 主题 | 说明 | | ---- | ---- | | [敏感数据过滤](../security/sensitive_data_filtering.zh.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | +| [Runtime Events 与事件日志](../architecture/runtime-events.zh.md) | 统一运行时事件、日志过滤和调试配置 | | [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | | [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | | [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | diff --git a/docs/guides/docker.fr.md b/docs/guides/docker.fr.md index ed0d14cf3..e174298ac 100644 --- a/docs/guides/docker.fr.md +++ b/docs/guides/docker.fr.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Mode Launcher (Console Web) -L'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat. +L'image `launcher` inclut les deux binaires (`picoclaw`, `picoclaw-launcher`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.ja.md b/docs/guides/docker.ja.md index 8fa5ae60c..19199aaac 100644 --- a/docs/guides/docker.ja.md +++ b/docs/guides/docker.ja.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher モード (Web コンソール) -`launcher` イメージには 3 つのバイナリ(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 +`launcher` イメージには 2 つのバイナリ(`picoclaw`、`picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.md b/docs/guides/docker.md index e017538f7..e2e472fbf 100644 --- a/docs/guides/docker.md +++ b/docs/guides/docker.md @@ -39,7 +39,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher Mode (Web Console) -The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. +The `launcher` image includes both binaries (`picoclaw`, `picoclaw-launcher`) and starts the web console by default, which provides a browser-based UI for configuration and chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.ms.md b/docs/guides/docker.ms.md index 7adab6759..5a426cb99 100644 --- a/docs/guides/docker.ms.md +++ b/docs/guides/docker.ms.md @@ -35,7 +35,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Mod Launcher (Konsol Web) -Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang. +Imej `launcher` merangkumi kedua-dua binari (`picoclaw`, `picoclaw-launcher`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.pt-br.md b/docs/guides/docker.pt-br.md index d7d55e753..ab71af8e6 100644 --- a/docs/guides/docker.pt-br.md +++ b/docs/guides/docker.pt-br.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Modo Launcher (Console Web) -A imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat. +A imagem `launcher` inclui ambos os binários (`picoclaw`, `picoclaw-launcher`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.vi.md b/docs/guides/docker.vi.md index 05f1b3d68..e91450bb0 100644 --- a/docs/guides/docker.vi.md +++ b/docs/guides/docker.vi.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Chế Độ Launcher (Web Console) -Image `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat. +Image `launcher` bao gồm cả hai binary (`picoclaw`, `picoclaw-launcher`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.zh.md b/docs/guides/docker.zh.md index bed445751..855375d9c 100644 --- a/docs/guides/docker.zh.md +++ b/docs/guides/docker.zh.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher 模式 (Web 控制台) -`launcher` 镜像包含所有三个二进制文件(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 +`launcher` 镜像包含两个二进制文件(`picoclaw`、`picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/providers.md b/docs/guides/providers.md index d99d8c016..7b078373d 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -116,23 +116,47 @@ This design also enables **multi-agent support** with flexible provider selectio #### `model_list` Entry Fields -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `model_name` | string | Yes | Unique name used to reference this model in agent config | -| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider | -| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported | -| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | -| `api_base` | string | No | Override the default API endpoint URL | -| `proxy` | string | No | HTTP proxy URL for this model entry | -| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) | -| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | -| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | -| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | -| `extra_body` | object | No | Additional fields to inject into every request body | +| Field | Type | Required | Description | +|-------|------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `model_name` | string | Yes | Unique name used to reference this model in agent config | +| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider | +| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported | +| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | +| `api_base` | string | No | Override the default API endpoint URL | +| `proxy` | string | No | HTTP proxy URL for this model entry | +| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) | +| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | +| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | +| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | +| `tool_schema_transform` | string | No | Optional compatibility transform for tool parameter schemas. Default: disabled. Supported values: `simple`. | +| `extra_body` | object | No | Additional fields to inject into every request body | | `custom_headers` | object | No | Additional HTTP headers to inject into every request (e.g., `{"X-Source":"coding-plan"}`). If a key matches a built-in header, the custom value overrides the built-in one (e.g., `Authorization`, `User-Agent`, `Content-Type`, `Accept`). | -| `rpm` | int | No | Per-minute request rate limit | -| `fallbacks` | string[] | No | Fallback model names for automatic failover | -| `enabled` | bool | No | Whether this model entry is active (default: `true`) | +| `rpm` | int | No | Per-minute request rate limit | +| `fallbacks` | string[] | No | Fallback model names for automatic failover | +| `enabled` | bool | No | Whether this model entry is active (default: `true`) | + +#### Tool Schema Compatibility + +By default, PicoClaw now forwards tool JSON Schemas unchanged. + +Some providers reject advanced JSON Schema features such as `$ref`, `$defs`, `anyOf`, `oneOf`, `allOf`, `pattern`, or numeric/string constraints inside tool declarations. For those models, you can opt into a compatibility transform per model entry with `tool_schema_transform`. + +Use `simple` when the upstream provider expects the conservative style function schema subset: + +```json +{ + "model_name": "gemini-2.5-flash-safe-tools", + "provider": "gemini", + "model": "gemini-2.5-flash", + "api_keys": ["your-gemini-key"], + "tool_schema_transform": "simple" +} +``` + +Notes: + +- Default behavior is disabled. If you omit `tool_schema_transform`, PicoClaw sends the original tool schema. +- The setting is per model entry, so you can enable it only for the providers that need it. #### Provider / Model Resolution @@ -393,10 +417,8 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a ], "agents": { "defaults": { - "model": { - "primary": "qwen-main", - "fallbacks": ["deepseek-backup", "gemini-backup"] - } + "model_name": "qwen-main", + "model_fallbacks": ["deepseek-backup", "gemini-backup"] } } } diff --git a/docs/guides/providers.zh.md b/docs/guides/providers.zh.md index 1302407a3..4bab65f6b 100644 --- a/docs/guides/providers.zh.md +++ b/docs/guides/providers.zh.md @@ -362,10 +362,8 @@ PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID ], "agents": { "defaults": { - "model": { - "primary": "qwen-main", - "fallbacks": ["deepseek-backup", "gemini-backup"] - } + "model_name": "qwen-main", + "model_fallbacks": ["deepseek-backup", "gemini-backup"] } } } diff --git a/docs/project/README.fr.md b/docs/project/README.fr.md index 1e2f59bee..b02067d2a 100644 --- a/docs/project/README.fr.md +++ b/docs/project/README.fr.md @@ -292,24 +292,6 @@ Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des l -### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH) - -Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Pour commencer :** - -Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer un Channel -> **3)** Démarrer le Gateway -> **4)** Chattez ! - -Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android diff --git a/docs/project/README.id.md b/docs/project/README.id.md index 244e6e49a..49c64e74c 100644 --- a/docs/project/README.id.md +++ b/docs/project/README.id.md @@ -289,24 +289,6 @@ Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pa -### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH) - -TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Memulai:** - -Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat! - -Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. diff --git a/docs/project/README.it.md b/docs/project/README.it.md index b3db6fece..0cf6cf8db 100644 --- a/docs/project/README.it.md +++ b/docs/project/README.it.md @@ -289,24 +289,6 @@ Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai -### 💻 TUI Launcher (Consigliato per Headless / SSH) - -Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Per iniziare:** - -Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare! - -Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. diff --git a/docs/project/README.ja.md b/docs/project/README.ja.md index 66d06ba5e..6e3060688 100644 --- a/docs/project/README.ja.md +++ b/docs/project/README.ja.md @@ -289,24 +289,6 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d -### 💻 TUI Launcher(ヘッドレス / SSH 向け推奨) - -TUI(Terminal UI)Launcher は設定と管理のためのフル機能ターミナルインターフェースを提供します。サーバー、Raspberry Pi、その他のヘッドレス環境に最適です。 - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**始め方:** - -TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を設定 → **3)** Gateway を起動 → **4)** チャット! - -TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。 - ### 📱 Android diff --git a/docs/project/README.ko.md b/docs/project/README.ko.md index cfc985688..dfefa67fe 100644 --- a/docs/project/README.ko.md +++ b/docs/project/README.ko.md @@ -289,24 +289,6 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 -### 💻 TUI Launcher (헤드리스 / SSH 권장) - -TUI(Terminal UI) Launcher는 설정과 관리를 위한 모든 기능을 갖춘 터미널 인터페이스를 제공합니다. 서버, Raspberry Pi, 기타 헤드리스 환경에 적합합니다. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**시작 방법:** - -TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 설정 -> **2)** 채널 설정 -> **3)** 게이트웨이 시작 -> **4)** 채팅! - -자세한 TUI 문서는 [docs.picoclaw.io](https://docs.picoclaw.io)를 참고하세요. - ### 📱 Android 오래된 스마트폰에 새 생명을 불어넣어 보세요! PicoClaw를 설치하면 스마트 AI 어시스턴트로 바꿀 수 있습니다. diff --git a/docs/project/README.ms.md b/docs/project/README.ms.md index f8c9e95e7..73c428f11 100644 --- a/docs/project/README.ms.md +++ b/docs/project/README.ms.md @@ -286,24 +286,6 @@ Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada p -### 💻 Pelancar TUI (Disyorkan untuk Headless / SSH) - -Pelancar TUI menyediakan antara muka terminal lengkap untuk konfigurasi dan pengurusan. Sesuai untuk pelayan, Raspberry Pi, dan persekitaran tanpa kepala lain. - -```bash -picoclaw-launcher-tui -``` - -

-Pelancar TUI -

- -**Memulakan:** - -Gunakan menu TUI untuk: **1)** Konfigurasikan Penyedia -> **2)** Konfigurasikan Saluran -> **3)** Mulakan Gateway -> **4)** Sembang! - -Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw. diff --git a/docs/project/README.pt-br.md b/docs/project/README.pt-br.md index 56d4ddd63..74cb967de 100644 --- a/docs/project/README.pt-br.md +++ b/docs/project/README.pt-br.md @@ -289,24 +289,6 @@ Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamen -### 💻 TUI Launcher (Recomendado para Headless / SSH) - -O TUI (Terminal UI) Launcher fornece uma interface de terminal completa para configuração e gerenciamento. Ideal para servidores, Raspberry Pi e outros ambientes headless. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Primeiros passos:** - -Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Channel -> **3)** Iniciar o Gateway -> **4)** Conversar! - -Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android diff --git a/docs/project/README.vi.md b/docs/project/README.vi.md index 52a56796b..743069021 100644 --- a/docs/project/README.vi.md +++ b/docs/project/README.vi.md @@ -289,24 +289,6 @@ Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần -### 💻 TUI Launcher (Khuyến nghị cho Headless / SSH) - -TUI (Terminal UI) Launcher cung cấp giao diện terminal đầy đủ tính năng để cấu hình và quản lý. Lý tưởng cho máy chủ, Raspberry Pi và các môi trường headless khác. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Bắt đầu:** - -Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Channel -> **3)** Khởi động Gateway -> **4)** Trò chuyện! - -Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android diff --git a/docs/project/README.zh.md b/docs/project/README.zh.md index a4fc892bd..253bb84ed 100644 --- a/docs/project/README.zh.md +++ b/docs/project/README.zh.md @@ -289,24 +289,6 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联 -### 💻 TUI Launcher(推荐无头环境 / SSH) - -TUI(终端 UI)Launcher 提供功能完整的终端配置与管理界面,适合服务器、树莓派等无显示器环境。 - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**开始使用:** - -通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天! - -详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。 - ### 📱 Android diff --git a/go.mod b/go.mod index cea8de00e..032bd1735 100644 --- a/go.mod +++ b/go.mod @@ -4,26 +4,24 @@ go 1.25.9 require ( fyne.io/systray v1.12.0 - github.com/BurntSushi/toml v1.6.0 github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/atc0005/go-teams-notify/v2 v2.14.0 - github.com/aws/aws-sdk-go-v2 v1.41.6 - github.com/aws/aws-sdk-go-v2/config v1.32.16 - github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5 + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 - github.com/gdamore/tcell/v2 v2.13.8 github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 - github.com/larksuite/oapi-sdk-go/v3 v3.5.4 + github.com/larksuite/oapi-sdk-go/v3 v3.6.1 github.com/line/line-bot-sdk-go/v8 v8.19.0 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 @@ -34,7 +32,6 @@ require ( github.com/openai/openai-go/v3 v3.22.0 github.com/pion/rtp v1.10.1 github.com/pion/webrtc/v3 v3.3.6 - github.com/rivo/tview v0.42.0 github.com/rs/zerolog v1.35.1 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 @@ -56,19 +53,19 @@ require ( require ( aead.dev/minisign v0.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect - github.com/aws/smithy-go v1.25.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beeper/argo-go v1.1.2 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect @@ -80,7 +77,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect - github.com/gdamore/encoding v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect diff --git a/go.sum b/go.sum index 7dbff8f9b..cd0baf329 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg= @@ -23,38 +21,38 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo= github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= -github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= -github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= -github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= -github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5 h1:ZGTl4Rxft1uyENAlGESY04hMzE4cLLNUPI7dGw08haw= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5/go.mod h1:jnugA+VgESQGgXuEKK6zVToET/DtODq7LQYpe+BkKT4= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= -github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6/go.mod h1:uY1fJe6m3I3w/m8UAkQ89Cm/ZAt/um6LW+AOZU33LDI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= @@ -105,10 +103,6 @@ github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4p github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY= 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/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= -github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= -github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0= github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -183,8 +177,8 @@ 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.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0= -github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4= github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -234,8 +228,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= -github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/pkg/agent/adapters/channelmanager.go b/pkg/agent/adapters/channelmanager.go index 8265ef99d..ad0840e86 100644 --- a/pkg/agent/adapters/channelmanager.go +++ b/pkg/agent/adapters/channelmanager.go @@ -43,3 +43,9 @@ func (a *channelManagerAdapter) SendMedia(ctx context.Context, msg bus.OutboundM func (a *channelManagerAdapter) SendPlaceholder(ctx context.Context, channel, chatID string) bool { return a.inner.SendPlaceholder(ctx, channel, chatID) } + +func (a *channelManagerAdapter) DismissToolFeedback( + ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext, +) { + a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx) +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 2c456dca7..97ee4fe7d 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -21,6 +21,7 @@ import ( "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" @@ -37,9 +38,13 @@ type AgentLoop struct { registry *AgentRegistry state *state.Manager - // Event system (from Incoming) - eventBus *EventBus - hooks *HookManager + // Runtime event system + runtimeEvents runtimeevents.Bus + ownsRuntimeEvents bool + runtimeEventLogMu sync.RWMutex + runtimeEventLogger *runtimeEventLogger + runtimeEventLogSub runtimeevents.Subscription + hooks *HookManager // Runtime state running atomic.Bool @@ -53,6 +58,7 @@ type AgentLoop struct { hookRuntime hookRuntime steering *steeringQueue pendingSkills sync.Map + pendingStops sync.Map mu sync.RWMutex // workerSem limits concurrent turn processing workers. @@ -172,6 +178,10 @@ func (al *AgentLoop) Run(ctx context.Context) error { phase: TurnPhaseSetup, } if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + if al.tryHandleStopCommand(ctx, msg, sessionKey) { + continue + } + // Another turn is already active (or reserved) for this session — enqueue if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ Role: "user", @@ -235,6 +245,24 @@ func (al *AgentLoop) Run(ctx context.Context) error { defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID) } + if al.takePendingStop(sessionKey) { + al.activeTurnStates.Delete(sessionKey) + target := &continuationTarget{ + SessionKey: sessionKey, + Channel: m.Channel, + ChatID: m.ChatID, + } + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + al.maybePublishError(ctx, m.Channel, m.ChatID, sessionKey, continueErr) + return + } + if continued != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued) + } + return + } + al.runTurnWithSteering(ctx, m) }(msg) @@ -285,8 +313,14 @@ func (al *AgentLoop) Close() { if al.hooks != nil { al.hooks.Close() } - if al.eventBus != nil { - al.eventBus.Close() + al.closeRuntimeEventLogger() + if al.runtimeEvents != nil && al.ownsRuntimeEvents { + if err := al.runtimeEvents.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close runtime event bus", + map[string]any{ + "error": err.Error(), + }) + } } } @@ -294,12 +328,6 @@ func (al *AgentLoop) Close() { // UnmountHook removes a previously registered in-process hook. -// SubscribeEvents registers a subscriber for agent-loop events. - -// UnsubscribeEvents removes a previously registered event subscriber. - -// EventDrops returns the number of dropped events for the given kind. - type turnEventScope struct { agentID string sessionKey string @@ -384,6 +412,7 @@ func (al *AgentLoop) ReloadProviderAndConfig( al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) al.mu.Unlock() + al.refreshRuntimeEventLogger(cfg) oldMCPManager := al.mcp.reset() al.hookRuntime.reset(al) diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index a2ed068d6..ae0293d71 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -274,6 +274,12 @@ func (al *AgentLoop) buildCommandsRuntime( return nil }, } + rt.StopActiveTurn = func() (commands.StopResult, error) { + if opts == nil { + return commands.StopResult{}, fmt.Errorf("process options not available") + } + return al.stopActiveTurnForSession(opts.Dispatch.SessionKey) + } if agent != nil && agent.ContextBuilder != nil { rt.ListSkillNames = agent.ContextBuilder.ListSkillNames } diff --git a/pkg/agent/agent_event.go b/pkg/agent/agent_event.go index 9b8625df1..99ea2a18e 100644 --- a/pkg/agent/agent_event.go +++ b/pkg/agent/agent_event.go @@ -5,7 +5,7 @@ package agent import ( "fmt" - "github.com/sipeed/picoclaw/pkg/logger" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope { @@ -18,8 +18,8 @@ func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *Turn } } -func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta { - return EventMeta{ +func (ts turnEventScope) meta(iteration int, source, tracePath string) HookMeta { + return HookMeta{ AgentID: ts.agentID, TurnID: ts.turnID, SessionKey: ts.sessionKey, @@ -30,119 +30,24 @@ func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta } } -func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) { - clonedMeta := cloneEventMeta(meta) - evt := Event{ - Kind: kind, - Meta: clonedMeta, - Context: cloneTurnContext(clonedMeta.turnContext), - Payload: payload, +func (al *AgentLoop) emitEvent(kind runtimeevents.Kind, meta HookMeta, payload any) { + clonedMeta := cloneHookMeta(meta) + eventCtx := cloneTurnContext(clonedMeta.turnContext) + evt := runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "agent", Name: clonedMeta.AgentID}, + Scope: runtimeScopeFromHookMeta(clonedMeta, eventCtx), + Correlation: runtimeCorrelationFromHookMeta(clonedMeta), + Severity: runtimeSeverityForAgentEvent(kind, payload), + Payload: payload, + Attrs: runtimeAttrsFromHookMeta(clonedMeta), } - if al == nil || al.eventBus == nil { + if al == nil { return } - al.logEvent(evt) - - al.eventBus.Emit(evt) -} - -func (al *AgentLoop) logEvent(evt Event) { - fields := map[string]any{ - "event_kind": evt.Kind.String(), - "agent_id": evt.Meta.AgentID, - "turn_id": evt.Meta.TurnID, - "session_key": evt.Meta.SessionKey, - "iteration": evt.Meta.Iteration, - } - - if evt.Meta.TracePath != "" { - fields["trace"] = evt.Meta.TracePath - } - if evt.Meta.Source != "" { - fields["source"] = evt.Meta.Source - } - - appendEventContextFields(fields, evt.Context) - - switch payload := evt.Payload.(type) { - case TurnStartPayload: - fields["user_len"] = len(payload.UserMessage) - fields["media_count"] = payload.MediaCount - case TurnEndPayload: - fields["status"] = payload.Status - fields["iterations_total"] = payload.Iterations - fields["duration_ms"] = payload.Duration.Milliseconds() - fields["final_len"] = payload.FinalContentLen - case LLMRequestPayload: - fields["model"] = payload.Model - fields["messages"] = payload.MessagesCount - fields["tools"] = payload.ToolsCount - fields["max_tokens"] = payload.MaxTokens - case LLMDeltaPayload: - fields["content_delta_len"] = payload.ContentDeltaLen - fields["reasoning_delta_len"] = payload.ReasoningDeltaLen - case LLMResponsePayload: - fields["content_len"] = payload.ContentLen - fields["tool_calls"] = payload.ToolCalls - fields["has_reasoning"] = payload.HasReasoning - case LLMRetryPayload: - fields["attempt"] = payload.Attempt - fields["max_retries"] = payload.MaxRetries - fields["reason"] = payload.Reason - fields["error"] = payload.Error - fields["backoff_ms"] = payload.Backoff.Milliseconds() - case ContextCompressPayload: - fields["reason"] = payload.Reason - fields["dropped_messages"] = payload.DroppedMessages - fields["remaining_messages"] = payload.RemainingMessages - case SessionSummarizePayload: - fields["summarized_messages"] = payload.SummarizedMessages - fields["kept_messages"] = payload.KeptMessages - fields["summary_len"] = payload.SummaryLen - fields["omitted_oversized"] = payload.OmittedOversized - case ToolExecStartPayload: - fields["tool"] = payload.Tool - fields["args_count"] = len(payload.Arguments) - case ToolExecEndPayload: - fields["tool"] = payload.Tool - fields["duration_ms"] = payload.Duration.Milliseconds() - fields["for_llm_len"] = payload.ForLLMLen - fields["for_user_len"] = payload.ForUserLen - fields["is_error"] = payload.IsError - fields["async"] = payload.Async - case ToolExecSkippedPayload: - fields["tool"] = payload.Tool - fields["reason"] = payload.Reason - case SteeringInjectedPayload: - fields["count"] = payload.Count - fields["total_content_len"] = payload.TotalContentLen - case FollowUpQueuedPayload: - fields["source_tool"] = payload.SourceTool - fields["content_len"] = payload.ContentLen - case InterruptReceivedPayload: - fields["interrupt_kind"] = payload.Kind - fields["role"] = payload.Role - fields["content_len"] = payload.ContentLen - fields["queue_depth"] = payload.QueueDepth - fields["hint_len"] = payload.HintLen - case SubTurnSpawnPayload: - fields["child_agent_id"] = payload.AgentID - fields["label"] = payload.Label - case SubTurnEndPayload: - fields["child_agent_id"] = payload.AgentID - fields["status"] = payload.Status - case SubTurnResultDeliveredPayload: - fields["target_channel"] = payload.TargetChannel - fields["target_chat_id"] = payload.TargetChatID - fields["content_len"] = payload.ContentLen - case ErrorPayload: - fields["stage"] = payload.Stage - fields["error"] = payload.Message - } - - logger.InfoCF("eventbus", fmt.Sprintf("Agent event: %s", evt.Kind.String()), fields) + al.publishRuntimeEvent(evt) } // MountHook registers an in-process hook on the agent loop. @@ -161,28 +66,26 @@ func (al *AgentLoop) UnmountHook(name string) { al.hooks.Unmount(name) } -// SubscribeEvents registers a subscriber for agent-loop events. -func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription { - if al == nil || al.eventBus == nil { - ch := make(chan Event) - close(ch) - return EventSubscription{C: ch} +// RuntimeEvents returns the root runtime event channel. +func (al *AgentLoop) RuntimeEvents() runtimeevents.EventChannel { + if al == nil || al.runtimeEvents == nil { + return nil } - return al.eventBus.Subscribe(buffer) + return al.runtimeEvents.Channel() } -// UnsubscribeEvents removes a previously registered event subscriber. -func (al *AgentLoop) UnsubscribeEvents(id uint64) { - if al == nil || al.eventBus == nil { - return +// RuntimeEventStats returns runtime event bus counters. +func (al *AgentLoop) RuntimeEventStats() runtimeevents.Stats { + if al == nil || al.runtimeEvents == nil { + return runtimeevents.Stats{Closed: true} } - al.eventBus.Unsubscribe(id) + return al.runtimeEvents.Stats() } -// EventDrops returns the number of dropped events for the given kind. -func (al *AgentLoop) EventDrops(kind EventKind) int64 { - if al == nil || al.eventBus == nil { - return 0 +// RuntimeEventBus returns the runtime event bus used by the agent loop. +func (al *AgentLoop) RuntimeEventBus() runtimeevents.Bus { + if al == nil { + return nil } - return al.eventBus.Dropped(kind) + return al.runtimeEvents } diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go index 611d634e8..e95fbe7f8 100644 --- a/pkg/agent/agent_init.go +++ b/pkg/agent/agent_init.go @@ -13,6 +13,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" @@ -24,6 +25,7 @@ func NewAgentLoop( cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, + opts ...AgentLoopOption, ) *AgentLoop { registry := NewAgentRegistry(cfg, provider) @@ -47,8 +49,6 @@ func NewAgentLoop( stateManager = state.NewManager(defaultAgent.Workspace) } - eventBus := NewEventBus() - // Determine worker pool size from config (default: 1 = sequential) workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns if workerPoolSize <= 0 { @@ -56,18 +56,28 @@ func NewAgentLoop( } al := &AgentLoop{ - bus: msgBus, - cfg: cfg, - registry: registry, - state: stateManager, - eventBus: eventBus, - fallback: fallbackChain, - cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), - steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), - workerSem: make(chan struct{}, workerPoolSize), + bus: msgBus, + cfg: cfg, + registry: registry, + state: stateManager, + fallback: fallbackChain, + cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), + steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), + workerSem: make(chan struct{}, workerPoolSize), + ownsRuntimeEvents: true, } + for _, opt := range opts { + if opt != nil { + opt(al) + } + } + if al.runtimeEvents == nil { + al.runtimeEvents = runtimeevents.NewBus() + al.ownsRuntimeEvents = true + } + al.refreshRuntimeEventLogger(cfg) al.providerFactory = providers.CreateProviderFromConfig - al.hooks = NewHookManager(eventBus) + al.hooks = NewHookManager(al.runtimeEvents.Channel()) configureHookManagerFromConfig(al.hooks, cfg) al.contextManager = al.resolveContextManager() @@ -128,6 +138,9 @@ func registerSharedTools( if cfg.Tools.IsToolEnabled("spi") { agent.Tools.Register(tools.NewSPITool()) } + if cfg.Tools.IsToolEnabled("serial") { + agent.Tools.Register(tools.NewSerialTool()) + } // Message tool if cfg.Tools.IsToolEnabled("message") { @@ -324,5 +337,20 @@ func registerSharedTools( } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) } + + // Register delegate tool for multi-agent setups. + // Auto-enabled when multiple agents exist. Delegation uses the SubTurn + // mechanism directly (not SubagentManager) and is independent of the + // subagent tool. + if len(registry.ListAgentIDs()) > 1 { + delegateTool := tools.NewDelegateTool() + delegateTool.SetSpawner(NewSubTurnSpawner(al)) + currentAgentID := agentID + delegateTool.SetSelfAgentID(currentAgentID) + delegateTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + agent.Tools.Register(delegateTool) + } } } diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index fcb57a5d4..b3c69504b 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -97,7 +97,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } al.mcp.initOnce.Do(func() { - mcpManager := mcp.NewManager() + mcpManager := mcp.NewManager(mcp.WithRuntimeEvents(al.runtimeEvents)) defaultAgent := al.registry.GetDefaultAgent() workspacePath := al.cfg.WorkspacePath() @@ -164,6 +164,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) mcpTool.SetWorkspace(agent.Workspace) mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars()) + mcpTool.SetEventPublisher(al.runtimeEvents) if registerAsHidden { agent.Tools.RegisterHidden(mcpTool) diff --git a/pkg/agent/agent_media.go b/pkg/agent/agent_media.go index a773d2ebb..c02c7392c 100644 --- a/pkg/agent/agent_media.go +++ b/pkg/agent/agent_media.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "io" "os" + "regexp" "strings" "github.com/h2non/filetype" @@ -20,24 +21,59 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +// genericPlaceholderRegex matches generic media placeholders emitted by various +// channels: [image], [image: photo], [image: filename.jpg] — but NOT path tags +// like [image:/path/to/file] (path tags have no space after the colon). +var ( + imagePlaceholderRegex = regexp.MustCompile(`\[image(:\s+[^\]]*)?\]`) + audioPlaceholderRegex = regexp.MustCompile(`\[audio(:\s+[^\]]*)?\]`) + videoPlaceholderRegex = regexp.MustCompile(`\[video(:\s+[^\]]*)?\]`) + filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`) +) + // resolveMediaRefs resolves media:// refs in messages. -// Images are base64-encoded into the Media array for multimodal LLMs. -// Non-image files (documents, audio, video) have their local path injected -// into Content so the agent can access them via file tools like read_file. +// For user messages: images get path tags only ([image:/path]) so the LLM +// can decide whether to view them via load_image or operate on the file. +// For tool messages: images are base64-encoded and appended as a synthetic +// user message only after the contiguous tool-message block ends, so we don't +// break the tool-results-must-immediately-follow-assistant constraint that +// LLM APIs enforce. +// Non-image files always get path tags regardless of role. // Returns a new slice; original messages are not mutated. func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { if store == nil { return messages } - result := make([]providers.Message, len(messages)) - copy(result, messages) + result := make([]providers.Message, 0, len(messages)) + var pendingToolImages []string + + for idx, m := range messages { + // When leaving a tool-message block, flush any accumulated images + // as a synthetic user message. + if m.Role != "tool" && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil + } - for i, m := range result { if len(m.Media) == 0 { + result = append(result, m) + if idx == len(messages)-1 && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil + } continue } + msg := m resolved := make([]string, 0, len(m.Media)) var pathTags []string @@ -66,27 +102,77 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS } mime := detectMIME(localPath, meta) + pathTags = append(pathTags, buildPathTag(mime, localPath)) - if strings.HasPrefix(mime, "image/") { + if m.Role == "tool" && strings.HasPrefix(mime, "image/") { dataURL := encodeImageToDataURL(localPath, mime, info, maxSize) if dataURL != "" { - resolved = append(resolved, dataURL) + pendingToolImages = append(pendingToolImages, dataURL) } - continue } - - pathTags = append(pathTags, buildPathTag(mime, localPath)) } - result[i].Media = resolved + msg.Media = resolved if len(pathTags) > 0 { - result[i].Content = injectPathTags(result[i].Content, pathTags) + msg.Content = injectPathTags(msg.Content, pathTags) + } + result = append(result, msg) + + // If this is the last message and we have pending images, flush them. + if idx == len(messages)-1 && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil } } return result } +// encodeImageToDataURL base64-encodes an image file into a data URL. +// Returns empty string if the file exceeds maxSize or encoding fails. +func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string { + if info.Size() > int64(maxSize) { + logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ + "path": localPath, + "size": info.Size(), + "max_size": maxSize, + }) + return "" + } + + f, err := os.Open(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + return "" + } + defer f.Close() + + prefix := "data:" + mime + ";base64," + encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) + var buf bytes.Buffer + buf.Grow(len(prefix) + encodedLen) + buf.WriteString(prefix) + + encoder := base64.NewEncoder(base64.StdEncoding, &buf) + if _, err := io.Copy(encoder, f); err != nil { + logger.WarnCF("agent", "Failed to encode media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + return "" + } + encoder.Close() + + return buf.String() +} + func buildArtifactTags(store media.MediaStore, refs []string) []string { if store == nil || len(refs) == 0 { return nil @@ -137,51 +223,12 @@ func detectMIME(localPath string, meta media.MediaMeta) string { return kind.MIME.Value } -// encodeImageToDataURL base64-encodes an image file into a data URL. -// Returns empty string if the file exceeds maxSize or encoding fails. -func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string { - if info.Size() > int64(maxSize) { - logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ - "path": localPath, - "size": info.Size(), - "max_size": maxSize, - }) - return "" - } - - f, err := os.Open(localPath) - if err != nil { - logger.WarnCF("agent", "Failed to open media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - return "" - } - defer f.Close() - - prefix := "data:" + mime + ";base64," - encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) - var buf bytes.Buffer - buf.Grow(len(prefix) + encodedLen) - buf.WriteString(prefix) - - encoder := base64.NewEncoder(base64.StdEncoding, &buf) - if _, err := io.Copy(encoder, f); err != nil { - logger.WarnCF("agent", "Failed to encode media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - return "" - } - encoder.Close() - - return buf.String() -} - // buildPathTag creates a structured tag exposing the local file path. -// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path]. +// Tag type is derived from MIME: [image:/path], [audio:/path], [video:/path], or [file:/path]. func buildPathTag(mime, localPath string) string { switch { + case strings.HasPrefix(mime, "image/"): + return "[image:" + localPath + "]" case strings.HasPrefix(mime, "audio/"): return "[audio:" + localPath + "]" case strings.HasPrefix(mime, "video/"): @@ -192,22 +239,41 @@ func buildPathTag(mime, localPath string) string { } // injectPathTags replaces generic media tags in content with path-bearing versions, -// or appends if no matching generic tag is found. +// or appends if no matching generic tag is found. Channels emit a few different +// placeholder formats — [image], [image: photo], [image: filename.jpg] — so we +// match all of them via regex while leaving path tags ([image:/path]) untouched. +// +// When content is structured data (e.g., JSON from Feishu interactive cards or +// post messages), tags are only injected via placeholder replacement — never +// appended — to avoid corrupting the payload. func injectPathTags(content string, tags []string) string { + isStructured := looksLikeJSON(content) for _, tag := range tags { - var generic string + var pattern *regexp.Regexp switch { + case strings.HasPrefix(tag, "[image:"): + pattern = imagePlaceholderRegex case strings.HasPrefix(tag, "[audio:"): - generic = "[audio]" + pattern = audioPlaceholderRegex case strings.HasPrefix(tag, "[video:"): - generic = "[video]" + pattern = videoPlaceholderRegex case strings.HasPrefix(tag, "[file:"): - generic = "[file]" + pattern = filePlaceholderRegex } - if generic != "" && strings.Contains(content, generic) { - content = strings.Replace(content, generic, tag, 1) - } else if content == "" { + if pattern != nil { + if loc := pattern.FindStringIndex(content); loc != nil { + content = content[:loc[0]] + tag + content[loc[1]:] + continue + } + } + + if isStructured { + content = tag + "\n" + content + continue + } + + if content == "" { content = tag } else { content += " " + tag @@ -215,3 +281,8 @@ func injectPathTags(content string, tags []string) string { } return content } + +func looksLikeJSON(s string) bool { + s = strings.TrimSpace(s) + return len(s) > 1 && s[0] == '{' +} diff --git a/pkg/agent/agent_options.go b/pkg/agent/agent_options.go new file mode 100644 index 000000000..224062a3f --- /dev/null +++ b/pkg/agent/agent_options.go @@ -0,0 +1,20 @@ +package agent + +import runtimeevents "github.com/sipeed/picoclaw/pkg/events" + +// AgentLoopOption configures an AgentLoop at construction time. +type AgentLoopOption func(*AgentLoop) + +// WithRuntimeEvents injects the runtime event bus used for new observation APIs. +// +// The injected bus is treated as externally owned and will not be closed by +// AgentLoop.Close. Passing nil leaves the default owned runtime bus enabled. +func WithRuntimeEvents(bus runtimeevents.Bus) AgentLoopOption { + return func(al *AgentLoop) { + if bus == nil { + return + } + al.runtimeEvents = bus + al.ownsRuntimeEvents = false + } +} diff --git a/pkg/agent/agent_steering.go b/pkg/agent/agent_steering.go index c674bcafa..9b136e7cd 100644 --- a/pkg/agent/agent_steering.go +++ b/pkg/agent/agent_steering.go @@ -44,11 +44,36 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb return } - // Drain steering queue using existing Continue mechanism + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + logger.WarnCF("agent", "Failed to continue queued steering", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "error": continueErr.Error(), + }) + } else if continued != "" { + finalResponse = continued + } + + // Publish final response + if finalResponse != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) + } +} + +func (al *AgentLoop) drainQueuedSteeringContinuations( + ctx context.Context, + target *continuationTarget, +) (string, error) { + if target == nil { + return "", nil + } + + finalResponse := "" for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - // Check for context cancellation between iterations - if ctx.Err() != nil { - return + if err := ctx.Err(); err != nil { + return finalResponse, err } logger.InfoCF("agent", "Continuing queued steering after turn end", @@ -61,13 +86,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { - logger.WarnCF("agent", "Failed to continue queued steering", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "error": continueErr.Error(), - }) - break + return finalResponse, continueErr } if continued == "" { break @@ -75,10 +94,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb finalResponse = continued } - // Publish final response - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) - } + return finalResponse, nil } func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { diff --git a/pkg/agent/agent_stop.go b/pkg/agent/agent_stop.go new file mode 100644 index 000000000..54cd51477 --- /dev/null +++ b/pkg/agent/agent_stop.go @@ -0,0 +1,122 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" +) + +func (al *AgentLoop) tryHandleStopCommand( + ctx context.Context, + msg bus.InboundMessage, + sessionKey string, +) bool { + cmdName, ok := commands.CommandName(msg.Content) + if !ok || cmdName != "stop" { + return false + } + + result, err := al.stopActiveTurnForSession(sessionKey) + + // This function is only called when loaded=true (another turn already + // claimed this session). If stopActiveTurnForSession found a pending + // placeholder but didn't stop it, that placeholder belongs to the other + // message's worker which hasn't started yet — arm a pending stop so the + // worker will bail when it checks before running. + if err == nil && !result.Stopped { + if ts := al.getActiveTurnState(sessionKey); ts != nil { + snap := ts.snapshot() + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + al.markPendingStop(sessionKey) + result.Stopped = true + } + } + } + + reply := commands.FormatStopReply(result) + if err != nil { + reply = "Failed to stop task: " + err.Error() + } + + if al.channelManager != nil { + al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + al.resetMessageToolRound(sessionKey) + al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, sessionKey, reply) + return true +} + +func (al *AgentLoop) stopActiveTurnForSession(sessionKey string) (commands.StopResult, error) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return commands.StopResult{}, fmt.Errorf("session key is required") + } + + result := commands.StopResult{} + cleared := al.clearSteeringMessagesForScope(sessionKey) + al.clearPendingSkills(sessionKey) + + ts := al.getActiveTurnState(sessionKey) + if ts == nil { + result.Stopped = cleared > 0 + return result, nil + } + + snap := ts.snapshot() + result.TaskName = snap.UserMessage + + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + // A pending placeholder means this session is either idle (our own + // placeholder from the /stop command) or another message is queued but + // hasn't started yet. In both cases, we don't arm a pending stop here; + // the caller (tryHandleStopCommand) handles the "another message queued" + // case explicitly, since it knows loaded=true. + return result, nil + } + + if err := al.HardAbort(sessionKey); err != nil { + if al.getActiveTurnState(sessionKey) == nil { + result.Stopped = cleared > 0 + return result, nil + } + return commands.StopResult{}, err + } + + result.Stopped = true + return result, nil +} + +func (al *AgentLoop) markPendingStop(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingStops.Store(sessionKey, struct{}{}) +} + +func (al *AgentLoop) takePendingStop(sessionKey string) bool { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return false + } + _, ok := al.pendingStops.LoadAndDelete(sessionKey) + return ok +} + +func (al *AgentLoop) resetMessageToolRound(sessionKey string) { + if strings.TrimSpace(sessionKey) == "" { + return + } + if registry := al.GetRegistry(); registry != nil { + if agent := registry.GetDefaultAgent(); agent != nil { + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + } + } +} diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 4047ab74d..a75919912 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" @@ -1781,17 +1782,22 @@ func (m *artifactThenSendProvider) Chat( if messages[i].Role != "tool" { continue } - start := strings.Index(messages[i].Content, "[file:") - if start < 0 { - continue + for _, prefix := range []string{"[image:", "[file:", "[audio:", "[video:"} { + start := strings.Index(messages[i].Content, prefix) + if start < 0 { + continue + } + rest := messages[i].Content[start+len(prefix):] + end := strings.Index(rest, "]") + if end < 0 { + continue + } + artifactPath = rest[:end] + break } - rest := messages[i].Content[start+len("[file:"):] - end := strings.Index(rest, "]") - if end < 0 { - continue + if artifactPath != "" { + break } - artifactPath = rest[:end] - break } if artifactPath == "" { return nil, fmt.Errorf("provider did not receive artifact path in tool result") @@ -4656,7 +4662,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi } } -func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { +func TestResolveMediaRefs_ImageInjectsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -4684,15 +4690,110 @@ func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media)) } - if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { - t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40]) + localPath, _, _ := store.ResolveWithMeta(ref) + expectedContent := "describe this [image:" + localPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } -func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { +func TestResolveMediaRefs_ToolRoleImageAppendedAsUserMessage(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "tool-result.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB + 0x00, 0x00, 0x00, // no interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + } + if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "tool", Content: "Image loaded", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + // Tool message should have path tag but no base64 + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media in tool message, got %d", len(result[0].Media)) + } + localPath, _, _ := store.ResolveWithMeta(ref) + if !strings.Contains(result[0].Content, "[image:"+localPath+"]") { + t.Fatalf("expected image path tag in tool content, got %q", result[0].Content) + } + + // A synthetic user message with base64 should follow + if len(result) != 2 { + t.Fatalf("expected 2 messages (tool + synthetic user), got %d", len(result)) + } + if result[1].Role != "user" { + t.Fatalf("expected synthetic message role=user, got %q", result[1].Role) + } + if len(result[1].Media) != 1 { + t.Fatalf("expected 1 base64 media in synthetic user message, got %d", len(result[1].Media)) + } + if !strings.HasPrefix(result[1].Media[0], "data:image/png;base64,") { + t.Fatalf("expected data:image/png;base64, prefix, got %q", result[1].Media[0][:40]) + } +} + +func TestResolveMediaRefs_MultiToolCallPreservesOrdering(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // Create image for tool #1 + pngPath := filepath.Join(dir, "loaded.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + // Simulate: assistant called load_image + read_file, two tool results follow + messages := []providers.Message{ + {Role: "assistant", Content: "Let me load the image and read the file."}, + {Role: "tool", Content: "Image loaded [image: photo]", Media: []string{imgRef}}, + {Role: "tool", Content: "file contents here"}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + // assistant, tool#1, tool#2 must remain contiguous — no user in between + if result[0].Role != "assistant" { + t.Fatalf("result[0] expected assistant, got %q", result[0].Role) + } + if result[1].Role != "tool" { + t.Fatalf("result[1] expected tool, got %q", result[1].Role) + } + if result[2].Role != "tool" { + t.Fatalf("result[2] expected tool, got %q", result[2].Role) + } + + // Synthetic user message should come AFTER the tool block + if len(result) != 4 { + t.Fatalf("expected 4 messages (assistant + 2 tool + synthetic user), got %d", len(result)) + } + if result[3].Role != "user" { + t.Fatalf("result[3] expected user, got %q", result[3].Role) + } + if len(result[3].Media) != 1 || !strings.HasPrefix(result[3].Media[0], "data:image/png;base64,") { + t.Fatal("expected synthetic user message to contain base64 image") + } +} + +func TestResolveMediaRefs_OversizedImageSkipsBase64KeepsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -4714,6 +4815,11 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { if len(result[0].Media) != 0 { t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) } + localPath, _, _ := store.ResolveWithMeta(ref) + expected := "hi [image:" + localPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } } func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) { @@ -4791,11 +4897,13 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 media, got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media)) } - if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") { - t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) + localPath, _, _ := store.ResolveWithMeta(ref) + expectedContent := "hi [image:" + localPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } @@ -4885,6 +4993,98 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) { } } +func TestInjectPathTags_HandlesVariousChannelPlaceholders(t *testing.T) { + cases := []struct { + name string + content string + tag string + want string + }{ + // Telegram / Feishu format + {"image_photo", "[image: photo]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + // WeCom / WeChat / Line format + {"bare_image", "[image]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + // QQ / Discord format with filename + {"image_filename", "[image: pic.jpg]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + {"audio_with_filename", "[audio: voice.m4a]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"}, + {"bare_audio", "[audio]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"}, + {"bare_video", "[video]", "[video:/tmp/v.mp4]", "[video:/tmp/v.mp4]"}, + {"bare_file", "[file]", "[file:/tmp/f.pdf]", "[file:/tmp/f.pdf]"}, + // Mixed surrounding text + { + "with_text", + "hello [image] world", + "[image:/tmp/p.png]", + "hello [image:/tmp/p.png] world", + }, + // No placeholder — append + {"no_placeholder", "hello world", "[image:/tmp/p.png]", "hello world [image:/tmp/p.png]"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := injectPathTags(tc.content, []string{tc.tag}) + if got != tc.want { + t.Errorf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestInjectPathTags_DoesNotReplacePathTag(t *testing.T) { + // If content already contains a path tag, we must not touch it. + content := "see [image:/already/placed.png] thanks" + got := injectPathTags(content, []string{"[image:/new/path.png]"}) + want := "see [image:/already/placed.png] thanks [image:/new/path.png]" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestInjectPathTags_PrependsForJSONContent(t *testing.T) { + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + got := injectPathTags(jsonContent, []string{"[image:/tmp/photo.png]"}) + want := "[image:/tmp/photo.png]\n" + jsonContent + if got != want { + t.Fatalf("expected tag prepended to JSON, got %q", got) + } +} + +func TestInjectPathTags_BracketTextNotTreatedAsJSON(t *testing.T) { + content := "[update] see attached report" + got := injectPathTags(content, []string{"[file:/tmp/report.pdf]"}) + want := "[update] see attached report [file:/tmp/report.pdf]" + if got != want { + t.Fatalf("expected tag appended to bracket text, got %q", got) + } +} + +func TestResolveMediaRefs_JSONContentPrependsPathTag(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "card_img.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + ref, _ := store.Store(pngPath, media.MediaMeta{ContentType: "image/png"}, "test") + + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + messages := []providers.Message{ + {Role: "user", Content: jsonContent, Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + want := "[image:" + pngPath + "]\n" + jsonContent + if result[0].Content != want { + t.Fatalf("expected path tag prepended to JSON content, got %q", result[0].Content) + } +} + func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -4928,13 +5128,12 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (all types use path tags), got %d", len(result[0].Media)) } - if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { - t.Fatal("expected image to be base64 encoded") - } - expectedContent := "check these [file:" + pdfPath + "]" + imgLocalPath, _, _ := store.ResolveWithMeta(imgRef) + pdfLocalPath, _, _ := store.ResolveWithMeta(fileRef) + expectedContent := "check these [file:" + pdfLocalPath + "] [image:" + imgLocalPath + "]" if result[0].Content != expectedContent { t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } @@ -5258,6 +5457,7 @@ func TestParallelMessageProcessing_SameSessionProcessedSequentially(t *testing.T var mu sync.Mutex turnIDs := make(map[string]bool) var wg sync.WaitGroup + var firstResponse sync.Once wg.Add(1) // Only 1 turn should be created for same session cfg := &config.Config{ @@ -5280,19 +5480,27 @@ func TestParallelMessageProcessing_SameSessionProcessedSequentially(t *testing.T al := NewAgentLoop(cfg, msgBus, &concurrentMockProvider{ responseFunc: func(callID int) string { - wg.Done() + firstResponse.Do(func() { + wg.Done() + }) return "ok" }, }) defer al.Close() - sub := al.SubscribeEvents(64) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 64, + runtimeevents.KindAgentTurnStart, + ) + defer closeRuntimeEvents() go func() { - for evt := range sub.C { - if evt.Kind == EventKindTurnStart { + for evt := range runtimeCh { + if evt.Kind == runtimeevents.KindAgentTurnStart { mu.Lock() - turnIDs[evt.Meta.TurnID] = true + turnIDs[evt.Scope.TurnID] = true mu.Unlock() } } diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index bbfb3f2ae..9228b6d55 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -4,7 +4,6 @@ package agent import ( "context" - "encoding/json" "fmt" "maps" "path/filepath" @@ -171,15 +170,8 @@ func toolFeedbackExplanationFromMessages(messages []providers.Message) string { } func toolFeedbackArgsPreview(args map[string]any, maxLen int) string { - if args == nil { - args = map[string]any{} - } - - argsJSON, err := json.MarshalIndent(args, "", " ") - if err != nil { - return utils.Truncate(fmt.Sprintf("%v", args), maxLen) - } - return utils.Truncate(string(argsJSON), maxLen) + argsJSON := utils.FormatArgsJSON(args, true, false) + return utils.Truncate(argsJSON, maxLen) } func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { @@ -293,6 +285,12 @@ func inferMediaType(filename, contentType string) string { ct := strings.ToLower(contentType) fn := strings.ToLower(filename) + // SVG is an image MIME type, but raster-only delivery endpoints such as + // Telegram SendPhoto reject it. Treat it as a file/document instead. + if strings.HasPrefix(ct, "image/svg") || filepath.Ext(fn) == ".svg" { + return "file" + } + if strings.HasPrefix(ct, "image/") { return "image" } @@ -306,7 +304,7 @@ func inferMediaType(filename, contentType string) string { // Fallback: infer from extension ext := filepath.Ext(fn) switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp": return "image" case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": return "audio" diff --git a/pkg/agent/agent_utils_test.go b/pkg/agent/agent_utils_test.go new file mode 100644 index 000000000..6612a60b3 --- /dev/null +++ b/pkg/agent/agent_utils_test.go @@ -0,0 +1,76 @@ +package agent + +import "testing" + +func TestInferMediaType(t *testing.T) { + tests := []struct { + name string + filename string + contentType string + want string + }{ + { + name: "png content type", + filename: "diagram", + contentType: "image/png", + want: "image", + }, + { + name: "jpeg extension fallback", + filename: "photo.JPG", + contentType: "", + want: "image", + }, + { + name: "svg content type is file", + filename: "diagram", + contentType: "image/svg+xml", + want: "file", + }, + { + name: "svg content type with parameters is file", + filename: "diagram", + contentType: "image/svg+xml; charset=utf-8", + want: "file", + }, + { + name: "svg extension fallback is file", + filename: "diagram.SVG", + contentType: "", + want: "file", + }, + { + name: "audio content type", + filename: "voice", + contentType: "audio/ogg", + want: "audio", + }, + { + name: "ogg application content type", + filename: "voice.ogg", + contentType: "application/ogg", + want: "audio", + }, + { + name: "video extension fallback", + filename: "clip.MP4", + contentType: "", + want: "video", + }, + { + name: "unknown type", + filename: "archive.bin", + contentType: "application/octet-stream", + want: "file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := inferMediaType(tt.filename, tt.contentType) + if got != tt.want { + t.Fatalf("inferMediaType(%q, %q) = %q, want %q", tt.filename, tt.contentType, got, tt.want) + } + }) + } +} diff --git a/pkg/agent/context_legacy.go b/pkg/agent/context_legacy.go index 5644571fb..94ef5367d 100644 --- a/pkg/agent/context_legacy.go +++ b/pkg/agent/context_legacy.go @@ -7,6 +7,7 @@ import ( "sync" "time" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -41,7 +42,7 @@ func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) e // Sync emergency compression — budget exceeded. if result, ok := m.forceCompression(req.SessionKey); ok { m.al.emitEvent( - EventKindContextCompress, + runtimeevents.KindAgentContextCompress, m.al.newTurnEventScope("", req.SessionKey, nil).meta(0, "forceCompression", "turn.context.compress"), ContextCompressPayload{ Reason: req.Reason, @@ -246,7 +247,7 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey agent.Sessions.TruncateHistory(sessionKey, keepCount) agent.Sessions.Save(sessionKey) m.al.emitEvent( - EventKindSessionSummarize, + runtimeevents.KindAgentSessionSummarize, m.al.newTurnEventScope(agent.ID, sessionKey, nil).meta(0, "summarizeSession", "turn.session.summarize"), SessionSummarizePayload{ SummarizedMessages: len(validMessages), diff --git a/pkg/agent/context_manager_test.go b/pkg/agent/context_manager_test.go index 629d11fcb..46e521be4 100644 --- a/pkg/agent/context_manager_test.go +++ b/pkg/agent/context_manager_test.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -305,8 +306,13 @@ func TestLegacyCompact_Overflow(t *testing.T) { } defaultAgent.Sessions.SetHistory("session-overflow", history) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentContextCompress, + ) + defer closeRuntimeEvents() err := al.contextManager.Compact(context.Background(), &CompactRequest{ SessionKey: "session-overflow", @@ -329,8 +335,8 @@ func TestLegacyCompact_Overflow(t *testing.T) { } // Event should carry the proactive reason - events := collectEventStream(sub.C) - compressEvt, ok := findEvent(events, EventKindContextCompress) + events := collectRuntimeEventStream(runtimeCh) + compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress) if !ok { t.Fatal("expected context compress event") } @@ -361,8 +367,13 @@ func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) { } defaultAgent.Sessions.SetHistory("session-proactive", history) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentContextCompress, + ) + defer closeRuntimeEvents() err := al.contextManager.Compact(context.Background(), &CompactRequest{ SessionKey: "session-proactive", @@ -372,8 +383,8 @@ func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - events := collectEventStream(sub.C) - compressEvt, ok := findEvent(events, EventKindContextCompress) + events := collectRuntimeEventStream(runtimeCh) + compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress) if !ok { t.Fatal("expected context compress event") } @@ -483,6 +494,14 @@ func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) { } defaultAgent.Sessions.SetHistory("session-threshold", history) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSessionSummarize, + ) + defer closeRuntimeEvents() + err := al.contextManager.Compact(context.Background(), &CompactRequest{ SessionKey: "session-threshold", Reason: ContextCompressReasonSummarize, @@ -491,12 +510,8 @@ func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // Wait for async summarization to complete via event - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) - - waitForEvent(t, sub.C, 5*time.Second, func(evt Event) bool { - return evt.Kind == EventKindSessionSummarize + waitForRuntimeEvent(t, runtimeCh, 5*time.Second, func(evt runtimeevents.Event) bool { + return evt.Kind == runtimeevents.KindAgentSessionSummarize }) newHistory := defaultAgent.Sessions.GetHistory("session-threshold") diff --git a/pkg/agent/event_payloads.go b/pkg/agent/event_payloads.go new file mode 100644 index 000000000..18fcbd4a0 --- /dev/null +++ b/pkg/agent/event_payloads.go @@ -0,0 +1,171 @@ +package agent + +import "time" + +// TurnEndStatus describes the terminal state of a turn. +type TurnEndStatus string + +const ( + // TurnEndStatusCompleted indicates the turn finished normally. + TurnEndStatusCompleted TurnEndStatus = "completed" + // TurnEndStatusError indicates the turn ended because of an error. + TurnEndStatusError TurnEndStatus = "error" + // TurnEndStatusAborted indicates the turn was hard-aborted and rolled back. + TurnEndStatusAborted TurnEndStatus = "aborted" +) + +// TurnStartPayload describes the start of a turn. +type TurnStartPayload struct { + UserMessage string + MediaCount int +} + +// TurnEndPayload describes the completion of a turn. +type TurnEndPayload struct { + Status TurnEndStatus + Iterations int + Duration time.Duration + FinalContentLen int +} + +// LLMRequestPayload describes an outbound LLM request. +type LLMRequestPayload struct { + Model string + MessagesCount int + ToolsCount int + MaxTokens int + Temperature float64 +} + +// LLMResponsePayload describes an inbound LLM response. +type LLMResponsePayload struct { + ContentLen int + ToolCalls int + HasReasoning bool +} + +// LLMDeltaPayload describes a streamed LLM delta. +type LLMDeltaPayload struct { + ContentDeltaLen int + ReasoningDeltaLen int +} + +// LLMRetryPayload describes a retry of an LLM request. +type LLMRetryPayload struct { + Attempt int + MaxRetries int + Reason string + Error string + Backoff time.Duration +} + +// ContextCompressReason identifies why emergency compression ran. +type ContextCompressReason string + +const ( + // ContextCompressReasonProactive indicates compression before the first LLM call. + ContextCompressReasonProactive ContextCompressReason = "proactive_budget" + // ContextCompressReasonRetry indicates compression during context-error retry handling. + ContextCompressReasonRetry ContextCompressReason = "llm_retry" + // ContextCompressReasonSummarize indicates post-turn async summarization. + ContextCompressReasonSummarize ContextCompressReason = "summarize" +) + +// ContextCompressPayload describes a forced history compression. +type ContextCompressPayload struct { + Reason ContextCompressReason + DroppedMessages int + RemainingMessages int +} + +// SessionSummarizePayload describes a completed async session summarization. +type SessionSummarizePayload struct { + SummarizedMessages int + KeptMessages int + SummaryLen int + OmittedOversized bool +} + +// ToolExecStartPayload describes a tool execution request. +type ToolExecStartPayload struct { + Tool string + Arguments map[string]any +} + +// ToolExecEndPayload describes the outcome of a tool execution. +type ToolExecEndPayload struct { + Tool string + Duration time.Duration + ForLLMLen int + ForUserLen int + IsError bool + Async bool +} + +// ToolExecSkippedPayload describes a skipped tool call. +type ToolExecSkippedPayload struct { + Tool string + Reason string +} + +// SteeringInjectedPayload describes steering messages appended before the next LLM call. +type SteeringInjectedPayload struct { + Count int + TotalContentLen int +} + +// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus. +type FollowUpQueuedPayload struct { + SourceTool string + ContentLen int +} + +type InterruptKind string + +const ( + InterruptKindSteering InterruptKind = "steering" + InterruptKindGraceful InterruptKind = "graceful" + InterruptKindHard InterruptKind = "hard_abort" +) + +// InterruptReceivedPayload describes accepted turn-control input. +type InterruptReceivedPayload struct { + Kind InterruptKind + Role string + ContentLen int + QueueDepth int + HintLen int +} + +// SubTurnSpawnPayload describes the creation of a child turn. +type SubTurnSpawnPayload struct { + AgentID string + Label string + ParentTurnID string +} + +// SubTurnEndPayload describes the completion of a child turn. +type SubTurnEndPayload struct { + AgentID string + Status string +} + +// SubTurnResultDeliveredPayload describes delivery of a sub-turn result. +type SubTurnResultDeliveredPayload struct { + TargetChannel string + TargetChatID string + ContentLen int +} + +// SubTurnOrphanPayload describes a sub-turn result that could not be delivered. +type SubTurnOrphanPayload struct { + ParentTurnID string + ChildTurnID string + Reason string +} + +// ErrorPayload describes an execution error inside the agent loop. +type ErrorPayload struct { + Stage string + Message string +} diff --git a/pkg/agent/eventbus.go b/pkg/agent/eventbus.go deleted file mode 100644 index 546d8436d..000000000 --- a/pkg/agent/eventbus.go +++ /dev/null @@ -1,121 +0,0 @@ -package agent - -import ( - "sync" - "sync/atomic" - "time" -) - -const defaultEventSubscriberBuffer = 16 - -// EventSubscription identifies a subscriber channel returned by EventBus.Subscribe. -type EventSubscription struct { - ID uint64 - C <-chan Event -} - -type eventSubscriber struct { - ch chan Event -} - -// EventBus is a lightweight multi-subscriber broadcaster for agent-loop events. -type EventBus struct { - mu sync.RWMutex - subs map[uint64]eventSubscriber - nextID uint64 - closed bool - dropped [eventKindCount]atomic.Int64 -} - -// NewEventBus creates a new in-process event broadcaster. -func NewEventBus() *EventBus { - return &EventBus{ - subs: make(map[uint64]eventSubscriber), - } -} - -// Subscribe registers a new subscriber with the requested channel buffer size. -// A non-positive buffer uses the default size. -func (b *EventBus) Subscribe(buffer int) EventSubscription { - if buffer <= 0 { - buffer = defaultEventSubscriberBuffer - } - - b.mu.Lock() - defer b.mu.Unlock() - - if b.closed { - ch := make(chan Event) - close(ch) - return EventSubscription{C: ch} - } - - b.nextID++ - id := b.nextID - ch := make(chan Event, buffer) - b.subs[id] = eventSubscriber{ch: ch} - return EventSubscription{ID: id, C: ch} -} - -// Unsubscribe removes a subscriber and closes its channel. -func (b *EventBus) Unsubscribe(id uint64) { - b.mu.Lock() - defer b.mu.Unlock() - - sub, ok := b.subs[id] - if !ok { - return - } - - delete(b.subs, id) - close(sub.ch) -} - -// Emit broadcasts an event to all current subscribers without blocking. -// When a subscriber channel is full, the event is dropped for that subscriber. -func (b *EventBus) Emit(evt Event) { - if evt.Time.IsZero() { - evt.Time = time.Now() - } - - b.mu.RLock() - defer b.mu.RUnlock() - - if b.closed { - return - } - - for _, sub := range b.subs { - select { - case sub.ch <- evt: - default: - if evt.Kind < eventKindCount { - b.dropped[evt.Kind].Add(1) - } - } - } -} - -// Dropped returns the number of dropped events for a given kind. -func (b *EventBus) Dropped(kind EventKind) int64 { - if kind >= eventKindCount { - return 0 - } - return b.dropped[kind].Load() -} - -// Close closes all subscriber channels and stops future broadcasts. -func (b *EventBus) Close() { - b.mu.Lock() - defer b.mu.Unlock() - - if b.closed { - return - } - - b.closed = true - for id, sub := range b.subs { - close(sub.ch) - delete(b.subs, id) - } -} diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 31b996260..86d7f4afa 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -9,61 +9,94 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" ) -func TestEventBus_SubscribeEmitUnsubscribeClose(t *testing.T) { - eventBus := NewEventBus() - sub := eventBus.Subscribe(1) - - eventBus.Emit(Event{ - Kind: EventKindTurnStart, - Meta: EventMeta{TurnID: "turn-1"}, - }) - - select { - case evt := <-sub.C: - if evt.Kind != EventKindTurnStart { - t.Fatalf("expected %v, got %v", EventKindTurnStart, evt.Kind) +func TestAgentLoop_PublishesRuntimeEvents(t *testing.T) { + runtimeBus := runtimeevents.NewBus() + al := &AgentLoop{ + runtimeEvents: runtimeBus, + } + defer func() { + if err := runtimeBus.Close(); err != nil { + t.Errorf("runtime bus close failed: %v", err) } - if evt.Meta.TurnID != "turn-1" { - t.Fatalf("expected turn id turn-1, got %q", evt.Meta.TurnID) + }() + + runtimeSub, runtimeCh, err := al.RuntimeEvents().OfKind(runtimeevents.KindAgentToolExecStart).SubscribeChan( + context.Background(), + runtimeevents.SubscribeOptions{Name: "runtime", Buffer: 1}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + defer func() { + if err := runtimeSub.Close(); err != nil { + t.Errorf("runtime subscription close failed: %v", err) } - case <-time.After(time.Second): - t.Fatal("timed out waiting for event") + }() + + al.emitEvent( + runtimeevents.KindAgentToolExecStart, + HookMeta{ + AgentID: "main", + TurnID: "turn-1", + ParentTurnID: "parent-turn", + SessionKey: "session-1", + Iteration: 2, + TracePath: "trace/root", + Source: "pipeline_execute", + turnContext: &TurnContext{ + Inbound: &bus.InboundContext{ + Channel: "cli", + Account: "default", + ChatID: "direct", + ChatType: "direct", + SenderID: "tester", + MessageID: "msg-1", + TopicID: "topic-1", + }, + }, + }, + ToolExecStartPayload{Tool: "mock_custom", Arguments: map[string]any{"task": "ping"}}, + ) + + runtimeEvt := receiveRuntimeEvent(t, runtimeCh) + if runtimeEvt.Kind != runtimeevents.KindAgentToolExecStart { + t.Fatalf("runtime kind = %q, want %q", runtimeEvt.Kind, runtimeevents.KindAgentToolExecStart) } - - eventBus.Unsubscribe(sub.ID) - if _, ok := <-sub.C; ok { - t.Fatal("expected subscriber channel to be closed after unsubscribe") + if runtimeEvt.Source != (runtimeevents.Source{Component: "agent", Name: "main"}) { + t.Fatalf("runtime source = %+v", runtimeEvt.Source) } - - eventBus.Close() - closedSub := eventBus.Subscribe(1) - if _, ok := <-closedSub.C; ok { - t.Fatal("expected closed bus to return a closed subscriber channel") + if runtimeEvt.Scope.AgentID != "main" || + runtimeEvt.Scope.SessionKey != "session-1" || + runtimeEvt.Scope.TurnID != "turn-1" || + runtimeEvt.Scope.Channel != "cli" || + runtimeEvt.Scope.Account != "default" || + runtimeEvt.Scope.ChatID != "direct" || + runtimeEvt.Scope.TopicID != "topic-1" || + runtimeEvt.Scope.ChatType != "direct" || + runtimeEvt.Scope.SenderID != "tester" || + runtimeEvt.Scope.MessageID != "msg-1" { + t.Fatalf("runtime scope = %+v", runtimeEvt.Scope) } -} - -func TestEventBus_DropsWhenSubscriberIsFull(t *testing.T) { - eventBus := NewEventBus() - sub := eventBus.Subscribe(1) - defer eventBus.Unsubscribe(sub.ID) - - start := time.Now() - for i := 0; i < 1000; i++ { - eventBus.Emit(Event{Kind: EventKindLLMRequest}) + if runtimeEvt.Correlation.TraceID != "trace/root" || + runtimeEvt.Correlation.ParentTurnID != "parent-turn" { + t.Fatalf("runtime correlation = %+v", runtimeEvt.Correlation) } - - if elapsed := time.Since(start); elapsed > 100*time.Millisecond { - t.Fatalf("Emit took too long with a blocked subscriber: %s", elapsed) + if runtimeEvt.Attrs["agent_source"] != "pipeline_execute" || runtimeEvt.Attrs["iteration"] != 2 { + t.Fatalf("runtime attrs = %+v", runtimeEvt.Attrs) } - - if got := eventBus.Dropped(EventKindLLMRequest); got != 999 { - t.Fatalf("expected 999 dropped events, got %d", got) + payload, ok := runtimeEvt.Payload.(ToolExecStartPayload) + if !ok { + t.Fatalf("runtime payload = %T, want ToolExecStartPayload", runtimeEvt.Payload) + } + if payload.Tool != "mock_custom" { + t.Fatalf("runtime payload tool = %q, want mock_custom", payload.Tool) } } @@ -127,8 +160,18 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { t.Fatal("expected default agent") } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + expectedKinds := []runtimeevents.Kind{ + runtimeevents.KindAgentTurnStart, + runtimeevents.KindAgentLLMRequest, + runtimeevents.KindAgentLLMResponse, + runtimeevents.KindAgentToolExecStart, + runtimeevents.KindAgentToolExecEnd, + runtimeevents.KindAgentLLMRequest, + runtimeevents.KindAgentLLMResponse, + runtimeevents.KindAgentTurnEnd, + } + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest(t, al, 16, expectedKinds...) + defer closeRuntimeEvents() response, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ SessionKey: "session-1", @@ -171,49 +214,36 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { t.Fatalf("expected final response 'done', got %q", response) } - events := collectEventStream(sub.C) + events := collectRuntimeEventStream(runtimeCh) if len(events) != 8 { t.Fatalf("expected 8 events, got %d", len(events)) } - kinds := make([]EventKind, 0, len(events)) + kinds := make([]runtimeevents.Kind, 0, len(events)) for _, evt := range events { kinds = append(kinds, evt.Kind) } - expectedKinds := []EventKind{ - EventKindTurnStart, - EventKindLLMRequest, - EventKindLLMResponse, - EventKindToolExecStart, - EventKindToolExecEnd, - EventKindLLMRequest, - EventKindLLMResponse, - EventKindTurnEnd, - } if !slices.Equal(kinds, expectedKinds) { t.Fatalf("unexpected event sequence: got %v want %v", kinds, expectedKinds) } - turnID := events[0].Meta.TurnID + turnID := events[0].Scope.TurnID + if turnID == "" { + t.Fatal("expected runtime events to include turn id") + } for i, evt := range events { - if evt.Meta.TurnID != turnID { - t.Fatalf("event %d has mismatched turn id %q, want %q", i, evt.Meta.TurnID, turnID) + if evt.Scope.TurnID != turnID { + t.Fatalf("event %d has mismatched turn id %q, want %q", i, evt.Scope.TurnID, turnID) } - if evt.Meta.SessionKey != "session-1" { - t.Fatalf("event %d has session key %q, want session-1", i, evt.Meta.SessionKey) + if evt.Scope.SessionKey != "session-1" { + t.Fatalf("event %d has session key %q, want session-1", i, evt.Scope.SessionKey) } - if evt.Context == nil || evt.Context.Inbound == nil { - t.Fatalf("event %d missing inbound turn context", i) + if evt.Scope.Channel != "cli" || evt.Scope.ChatID != "direct" || evt.Scope.SenderID != "tester" { + t.Fatalf("event %d scope = %+v", i, evt.Scope) } - if evt.Context.Inbound.Channel != "cli" || evt.Context.Inbound.SenderID != "tester" { - t.Fatalf("event %d inbound context = %+v", i, evt.Context.Inbound) - } - if evt.Context.Route == nil || evt.Context.Route.AgentID != "main" { - t.Fatalf("event %d missing route context: %+v", i, evt.Context.Route) - } - if evt.Context.Scope == nil || evt.Context.Scope.Values["sender"] != "tester" { - t.Fatalf("event %d missing session scope: %+v", i, evt.Context.Scope) + if evt.Scope.AgentID != "main" { + t.Fatalf("event %d has agent id %q, want main", i, evt.Scope.AgentID) } } @@ -309,8 +339,15 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { al.RegisterTool(tool1) al.RegisterTool(tool2) - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentSteeringInjected, + runtimeevents.KindAgentToolExecSkipped, + runtimeevents.KindAgentInterruptReceived, + ) + defer closeRuntimeEvents() resultCh := make(chan string, 1) go func() { @@ -337,8 +374,8 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatal("timeout waiting for steered response") } - events := collectEventStream(sub.C) - steeringEvt, ok := findEvent(events, EventKindSteeringInjected) + events := collectRuntimeEventStream(runtimeCh) + steeringEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentSteeringInjected) if !ok { t.Fatal("expected steering injected event") } @@ -350,7 +387,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatalf("expected 1 steering message, got %d", steeringPayload.Count) } - skippedEvt, ok := findEvent(events, EventKindToolExecSkipped) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) if !ok { t.Fatal("expected skipped tool event") } @@ -362,7 +399,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatalf("expected skipped tool_two, got %q", skippedPayload.Tool) } - interruptEvt, ok := findEvent(events, EventKindInterruptReceived) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) if !ok { t.Fatal("expected interrupt received event") } @@ -420,8 +457,14 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { {Role: "user", Content: "Trigger message"}, }) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentLLMRetry, + runtimeevents.KindAgentContextCompress, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ SessionKey: "session-1", @@ -439,8 +482,8 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { t.Fatalf("expected retry success, got %q", resp) } - events := collectEventStream(sub.C) - retryEvt, ok := findEvent(events, EventKindLLMRetry) + events := collectRuntimeEventStream(runtimeCh) + retryEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentLLMRetry) if !ok { t.Fatal("expected llm retry event") } @@ -455,7 +498,7 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { t.Fatalf("expected retry attempt 1, got %d", retryPayload.Attempt) } - compressEvt, ok := findEvent(events, EventKindContextCompress) + compressEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentContextCompress) if !ok { t.Fatal("expected context compress event") } @@ -508,14 +551,19 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) { {Role: "assistant", Content: "Answer three"}, }) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSessionSummarize, + ) + defer closeRuntimeEvents() lcm := &legacyContextManager{al: al} lcm.summarizeSession(defaultAgent, "session-1") - events := collectEventStream(sub.C) - summaryEvt, ok := findEvent(events, EventKindSessionSummarize) + events := collectRuntimeEventStream(runtimeCh) + summaryEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentSessionSummarize) if !ok { t.Fatal("expected session summarize event") } @@ -575,8 +623,13 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { t.Fatal("expected default agent") } - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentFollowUpQueued, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ SessionKey: "session-1", @@ -600,8 +653,8 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { t.Fatal("timeout waiting for async tool completion") } - followUpEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool { - return evt.Kind == EventKindFollowUpQueued + followUpEvt := waitForRuntimeEvent(t, runtimeCh, 2*time.Second, func(evt runtimeevents.Event) bool { + return evt.Kind == runtimeevents.KindAgentFollowUpQueued }) payload, ok := followUpEvt.Payload.(FollowUpQueuedPayload) if !ok { @@ -613,59 +666,29 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { if payload.ContentLen != len("background result") { t.Fatalf("expected content len %d, got %d", len("background result"), payload.ContentLen) } - if followUpEvt.Meta.SessionKey != "session-1" { - t.Fatalf("expected session key session-1, got %q", followUpEvt.Meta.SessionKey) + if followUpEvt.Scope.SessionKey != "session-1" { + t.Fatalf("expected session key session-1, got %q", followUpEvt.Scope.SessionKey) } - if followUpEvt.Meta.TurnID == "" { + if followUpEvt.Scope.TurnID == "" { t.Fatal("expected follow-up event to include turn id") } } -func collectEventStream(ch <-chan Event) []Event { - var events []Event - for { - select { - case evt, ok := <-ch: - if !ok { - return events - } - events = append(events, evt) - default: - return events - } - } -} - -func waitForEvent(t *testing.T, ch <-chan Event, timeout time.Duration, match func(Event) bool) Event { +func receiveRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { t.Helper() - timer := time.NewTimer(timeout) - defer timer.Stop() - - for { - select { - case evt, ok := <-ch: - if !ok { - t.Fatal("event stream closed before expected event arrived") - } - if match(evt) { - return evt - } - case <-timer.C: - t.Fatal("timed out waiting for expected event") + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event stream closed before expected event arrived") } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} } } -func findEvent(events []Event, kind EventKind) (Event, bool) { - for _, evt := range events { - if evt.Kind == kind { - return evt, true - } - } - return Event{}, false -} - type stringError string func (e stringError) Error() string { diff --git a/pkg/agent/events.go b/pkg/agent/events.go index f68d3eab5..0dd861f43 100644 --- a/pkg/agent/events.go +++ b/pkg/agent/events.go @@ -1,97 +1,8 @@ package agent -import ( - "fmt" - "time" -) - -// EventKind identifies a structured agent-loop event. -type EventKind uint8 - -const ( - // EventKindTurnStart is emitted when a turn begins processing. - EventKindTurnStart EventKind = iota - // EventKindTurnEnd is emitted when a turn finishes, successfully or with an error. - EventKindTurnEnd - // EventKindLLMRequest is emitted before a provider chat request is made. - EventKindLLMRequest - // EventKindLLMDelta is emitted when a streaming provider yields a partial delta. - EventKindLLMDelta - // EventKindLLMResponse is emitted after a provider chat response is received. - EventKindLLMResponse - // EventKindLLMRetry is emitted when an LLM request is retried. - EventKindLLMRetry - // EventKindContextCompress is emitted when session history is forcibly compressed. - EventKindContextCompress - // EventKindSessionSummarize is emitted when asynchronous summarization completes. - EventKindSessionSummarize - // EventKindToolExecStart is emitted immediately before a tool executes. - EventKindToolExecStart - // EventKindToolExecEnd is emitted immediately after a tool finishes executing. - EventKindToolExecEnd - // EventKindToolExecSkipped is emitted when a queued tool call is skipped. - EventKindToolExecSkipped - // EventKindSteeringInjected is emitted when queued steering is injected into context. - EventKindSteeringInjected - // EventKindFollowUpQueued is emitted when an async tool queues a follow-up system message. - EventKindFollowUpQueued - // EventKindInterruptReceived is emitted when a soft interrupt message is accepted. - EventKindInterruptReceived - // EventKindSubTurnSpawn is emitted when a sub-turn is spawned. - EventKindSubTurnSpawn - // EventKindSubTurnEnd is emitted when a sub-turn finishes. - EventKindSubTurnEnd - // EventKindSubTurnResultDelivered is emitted when a sub-turn result is delivered. - EventKindSubTurnResultDelivered - // EventKindSubTurnOrphan is emitted when a sub-turn result cannot be delivered. - EventKindSubTurnOrphan - // EventKindError is emitted when a turn encounters an execution error. - EventKindError - - eventKindCount -) - -var eventKindNames = [...]string{ - "turn_start", - "turn_end", - "llm_request", - "llm_delta", - "llm_response", - "llm_retry", - "context_compress", - "session_summarize", - "tool_exec_start", - "tool_exec_end", - "tool_exec_skipped", - "steering_injected", - "follow_up_queued", - "interrupt_received", - "subturn_spawn", - "subturn_end", - "subturn_result_delivered", - "subturn_orphan", - "error", -} - -// String returns the stable string form of an EventKind. -func (k EventKind) String() string { - if k >= eventKindCount { - return fmt.Sprintf("event_kind(%d)", k) - } - return eventKindNames[k] -} - -// Event is the structured envelope broadcast by the agent EventBus. -type Event struct { - Kind EventKind - Time time.Time - Meta EventMeta - Context *TurnContext - Payload any -} - -// EventMeta contains correlation fields shared by all agent-loop events. -type EventMeta struct { +// HookMeta contains correlation fields shared by agent hook requests and +// runtime events emitted from turn processing. +type HookMeta struct { AgentID string TurnID string ParentTurnID string @@ -101,171 +12,3 @@ type EventMeta struct { Source string turnContext *TurnContext } - -// TurnEndStatus describes the terminal state of a turn. -type TurnEndStatus string - -const ( - // TurnEndStatusCompleted indicates the turn finished normally. - TurnEndStatusCompleted TurnEndStatus = "completed" - // TurnEndStatusError indicates the turn ended because of an error. - TurnEndStatusError TurnEndStatus = "error" - // TurnEndStatusAborted indicates the turn was hard-aborted and rolled back. - TurnEndStatusAborted TurnEndStatus = "aborted" -) - -// TurnStartPayload describes the start of a turn. -type TurnStartPayload struct { - UserMessage string - MediaCount int -} - -// TurnEndPayload describes the completion of a turn. -type TurnEndPayload struct { - Status TurnEndStatus - Iterations int - Duration time.Duration - FinalContentLen int -} - -// LLMRequestPayload describes an outbound LLM request. -type LLMRequestPayload struct { - Model string - MessagesCount int - ToolsCount int - MaxTokens int - Temperature float64 -} - -// LLMResponsePayload describes an inbound LLM response. -type LLMResponsePayload struct { - ContentLen int - ToolCalls int - HasReasoning bool -} - -// LLMDeltaPayload describes a streamed LLM delta. -type LLMDeltaPayload struct { - ContentDeltaLen int - ReasoningDeltaLen int -} - -// LLMRetryPayload describes a retry of an LLM request. -type LLMRetryPayload struct { - Attempt int - MaxRetries int - Reason string - Error string - Backoff time.Duration -} - -// ContextCompressReason identifies why emergency compression ran. -type ContextCompressReason string - -const ( - // ContextCompressReasonProactive indicates compression before the first LLM call. - ContextCompressReasonProactive ContextCompressReason = "proactive_budget" - // ContextCompressReasonRetry indicates compression during context-error retry handling. - ContextCompressReasonRetry ContextCompressReason = "llm_retry" - // ContextCompressReasonSummarize indicates post-turn async summarization. - ContextCompressReasonSummarize ContextCompressReason = "summarize" -) - -// ContextCompressPayload describes a forced history compression. -type ContextCompressPayload struct { - Reason ContextCompressReason - DroppedMessages int - RemainingMessages int -} - -// SessionSummarizePayload describes a completed async session summarization. -type SessionSummarizePayload struct { - SummarizedMessages int - KeptMessages int - SummaryLen int - OmittedOversized bool -} - -// ToolExecStartPayload describes a tool execution request. -type ToolExecStartPayload struct { - Tool string - Arguments map[string]any -} - -// ToolExecEndPayload describes the outcome of a tool execution. -type ToolExecEndPayload struct { - Tool string - Duration time.Duration - ForLLMLen int - ForUserLen int - IsError bool - Async bool -} - -// ToolExecSkippedPayload describes a skipped tool call. -type ToolExecSkippedPayload struct { - Tool string - Reason string -} - -// SteeringInjectedPayload describes steering messages appended before the next LLM call. -type SteeringInjectedPayload struct { - Count int - TotalContentLen int -} - -// FollowUpQueuedPayload describes an async follow-up queued back into the inbound bus. -type FollowUpQueuedPayload struct { - SourceTool string - ContentLen int -} - -type InterruptKind string - -const ( - InterruptKindSteering InterruptKind = "steering" - InterruptKindGraceful InterruptKind = "graceful" - InterruptKindHard InterruptKind = "hard_abort" -) - -// InterruptReceivedPayload describes accepted turn-control input. -type InterruptReceivedPayload struct { - Kind InterruptKind - Role string - ContentLen int - QueueDepth int - HintLen int -} - -// SubTurnSpawnPayload describes the creation of a child turn. -type SubTurnSpawnPayload struct { - AgentID string - Label string - ParentTurnID string -} - -// SubTurnEndPayload describes the completion of a child turn. -type SubTurnEndPayload struct { - AgentID string - Status string -} - -// SubTurnResultDeliveredPayload describes delivery of a sub-turn result. -type SubTurnResultDeliveredPayload struct { - TargetChannel string - TargetChatID string - ContentLen int -} - -// SubTurnOrphanPayload describes a sub-turn result that could not be delivered. -type SubTurnOrphanPayload struct { - ParentTurnID string - ChildTurnID string - Reason string -} - -// ErrorPayload describes an execution error inside the agent loop. -type ErrorPayload struct { - Stage string - Message string -} diff --git a/pkg/agent/events_runtime.go b/pkg/agent/events_runtime.go new file mode 100644 index 000000000..2284665e6 --- /dev/null +++ b/pkg/agent/events_runtime.go @@ -0,0 +1,88 @@ +package agent + +import runtimeevents "github.com/sipeed/picoclaw/pkg/events" + +func (al *AgentLoop) publishRuntimeEvent(evt runtimeevents.Event) { + if al == nil || al.runtimeEvents == nil { + return + } + + al.runtimeEvents.PublishNonBlocking(evt) +} + +func runtimeScopeFromHookMeta(meta HookMeta, eventCtx *TurnContext) runtimeevents.Scope { + scope := runtimeevents.Scope{ + AgentID: meta.AgentID, + SessionKey: meta.SessionKey, + TurnID: meta.TurnID, + } + + if eventCtx == nil || eventCtx.Inbound == nil { + return scope + } + + inbound := eventCtx.Inbound + scope.Channel = inbound.Channel + scope.Account = inbound.Account + scope.ChatID = inbound.ChatID + scope.TopicID = inbound.TopicID + scope.SpaceID = inbound.SpaceID + scope.SpaceType = inbound.SpaceType + scope.ChatType = inbound.ChatType + scope.SenderID = inbound.SenderID + scope.MessageID = inbound.MessageID + return scope +} + +func runtimeCorrelationFromHookMeta(meta HookMeta) runtimeevents.Correlation { + return runtimeevents.Correlation{ + TraceID: meta.TracePath, + ParentTurnID: meta.ParentTurnID, + } +} + +func runtimeSeverityForAgentEvent(kind runtimeevents.Kind, payload any) runtimeevents.Severity { + switch kind { + case runtimeevents.KindAgentError, runtimeevents.KindAgentSubTurnOrphan: + return runtimeevents.SeverityError + case runtimeevents.KindAgentLLMRetry, + runtimeevents.KindAgentContextCompress, + runtimeevents.KindAgentToolExecSkipped: + return runtimeevents.SeverityWarn + case runtimeevents.KindAgentTurnEnd: + payload, ok := payload.(TurnEndPayload) + if !ok { + return runtimeevents.SeverityInfo + } + switch payload.Status { + case TurnEndStatusError: + return runtimeevents.SeverityError + case TurnEndStatusAborted: + return runtimeevents.SeverityWarn + default: + return runtimeevents.SeverityInfo + } + case runtimeevents.KindAgentToolExecEnd: + payload, ok := payload.(ToolExecEndPayload) + if ok && payload.IsError { + return runtimeevents.SeverityWarn + } + return runtimeevents.SeverityInfo + default: + return runtimeevents.SeverityInfo + } +} + +func runtimeAttrsFromHookMeta(meta HookMeta) map[string]any { + attrs := make(map[string]any, 2) + if meta.Source != "" { + attrs["agent_source"] = meta.Source + } + if meta.Iteration != 0 { + attrs["iteration"] = meta.Iteration + } + if len(attrs) == 0 { + return nil + } + return attrs +} diff --git a/pkg/agent/hook_mount.go b/pkg/agent/hook_mount.go index c92145f1f..c518feee8 100644 --- a/pkg/agent/hook_mount.go +++ b/pkg/agent/hook_mount.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) type hookRuntime struct { @@ -295,10 +296,11 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error) case "", "*", "all": return nil, true, nil default: - if _, ok := validKinds[kind]; !ok { + normalizedKind, ok := validKinds[kind] + if !ok { return nil, false, fmt.Errorf("unsupported observe event %q", kind) } - normalized = append(normalized, kind) + normalized = append(normalized, normalizedKind) } } @@ -308,10 +310,30 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error) return normalized, true, nil } -func validHookEventKinds() map[string]struct{} { - kinds := make(map[string]struct{}, int(eventKindCount)) - for kind := EventKind(0); kind < eventKindCount; kind++ { - kinds[kind.String()] = struct{}{} +func validHookEventKinds() map[string]string { + runtimeKinds := runtimeevents.KnownKinds() + kinds := make(map[string]string, len(runtimeKinds)*2) + for _, kind := range runtimeKinds { + kinds[kind.String()] = kind.String() } + kinds["turn_start"] = runtimeevents.KindAgentTurnStart.String() + kinds["turn_end"] = runtimeevents.KindAgentTurnEnd.String() + kinds["llm_request"] = runtimeevents.KindAgentLLMRequest.String() + kinds["llm_delta"] = runtimeevents.KindAgentLLMDelta.String() + kinds["llm_response"] = runtimeevents.KindAgentLLMResponse.String() + kinds["llm_retry"] = runtimeevents.KindAgentLLMRetry.String() + kinds["context_compress"] = runtimeevents.KindAgentContextCompress.String() + kinds["session_summarize"] = runtimeevents.KindAgentSessionSummarize.String() + kinds["tool_exec_start"] = runtimeevents.KindAgentToolExecStart.String() + kinds["tool_exec_end"] = runtimeevents.KindAgentToolExecEnd.String() + kinds["tool_exec_skipped"] = runtimeevents.KindAgentToolExecSkipped.String() + kinds["steering_injected"] = runtimeevents.KindAgentSteeringInjected.String() + kinds["follow_up_queued"] = runtimeevents.KindAgentFollowUpQueued.String() + kinds["interrupt_received"] = runtimeevents.KindAgentInterruptReceived.String() + kinds["subturn_spawn"] = runtimeevents.KindAgentSubTurnSpawn.String() + kinds["subturn_end"] = runtimeevents.KindAgentSubTurnEnd.String() + kinds["subturn_result_delivered"] = runtimeevents.KindAgentSubTurnResultDelivered.String() + kinds["subturn_orphan"] = runtimeevents.KindAgentSubTurnOrphan.String() + kinds["error"] = runtimeevents.KindAgentError.String() return kinds } diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go index 85d8f5c11..5cd64af7b 100644 --- a/pkg/agent/hook_mount_test.go +++ b/pkg/agent/hook_mount_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "path/filepath" + "slices" "testing" "github.com/sipeed/picoclaw/pkg/bus" @@ -155,7 +156,27 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T) t.Fatalf("expected process model, got %q", lastModel) } - waitForFileContains(t, eventLog, "turn_end") + waitForFileContains(t, eventLog, "agent.turn.end") +} + +func TestProcessHookObserveKindsFromConfigAcceptsRuntimeNames(t *testing.T) { + kinds, enabled, err := processHookObserveKindsFromConfig([]string{ + "tool_exec_start", + "agent.tool.exec_end", + "gateway.ready", + "mcp.server.failed", + }) + if err != nil { + t.Fatalf("processHookObserveKindsFromConfig failed: %v", err) + } + if !enabled { + t.Fatal("expected observe to be enabled") + } + + want := []string{"agent.tool.exec_start", "agent.tool.exec_end", "gateway.ready", "mcp.server.failed"} + if !slices.Equal(kinds, want) { + t.Fatalf("observe kinds = %v, want %v", kinds, want) + } } func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testing.T) { diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go index ace95f44d..ce8e932d2 100644 --- a/pkg/agent/hook_process.go +++ b/pkg/agent/hook_process.go @@ -12,6 +12,7 @@ import ( "sync/atomic" "time" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/tools" @@ -183,7 +184,7 @@ func (ph *ProcessHook) Close() error { return ph.closeErr } -func (ph *ProcessHook) OnEvent(ctx context.Context, evt Event) error { +func (ph *ProcessHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { if ph == nil || !ph.opts.Observe { return nil } @@ -192,7 +193,7 @@ func (ph *ProcessHook) OnEvent(ctx context.Context, evt Event) error { return nil } } - return ph.notify(ctx, "hook.event", evt) + return ph.notify(ctx, "hook.runtime_event", evt) } func (ph *ProcessHook) BeforeLLM( diff --git a/pkg/agent/hook_process_test.go b/pkg/agent/hook_process_test.go index 9e95d105e..0fd1ec38d 100644 --- a/pkg/agent/hook_process_test.go +++ b/pkg/agent/hook_process_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -66,7 +67,7 @@ func TestAgentLoop_MountProcessHook_LLMAndObserver(t *testing.T) { t.Fatalf("expected process model, got %q", lastModel) } - waitForFileContains(t, eventLog, "turn_end") + waitForFileContains(t, eventLog, "agent.turn.end") } func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) { @@ -146,8 +147,13 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { t.Fatalf("MountProcessHook failed: %v", err) } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ SessionKey: "session-1", @@ -167,8 +173,8 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { t.Fatalf("expected %q, got %q", expected, resp) } - events := collectEventStream(sub.C) - skippedEvt, ok := findEvent(events, EventKindToolExecSkipped) + events := collectRuntimeEventStream(runtimeCh) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) if !ok { t.Fatal("expected tool skipped event") } @@ -350,12 +356,11 @@ func runProcessHookHelper() error { } if msg.ID == 0 { - if msg.Method == "hook.event" && eventLog != "" { + if msg.Method == "hook.runtime_event" && eventLog != "" { var evt map[string]any if err := json.Unmarshal(msg.Params, &evt); err == nil { - if rawKind, ok := evt["Kind"].(float64); ok { - kind := EventKind(rawKind) - _ = os.WriteFile(eventLog, []byte(kind.String()+"\n"), 0o644) + if kind, ok := evt["kind"].(string); ok { + _ = os.WriteFile(eventLog, []byte(kind+"\n"), 0o644) } } } diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index 9cc3e6951..a4f0fac82 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -9,6 +9,7 @@ import ( "sync" "time" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" @@ -71,8 +72,8 @@ func NamedHook(name string, hook any) HookRegistration { } } -type EventObserver interface { - OnEvent(ctx context.Context, evt Event) error +type RuntimeEventObserver interface { + OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error } type LLMInterceptor interface { @@ -90,7 +91,7 @@ type ToolApprover interface { } type LLMHookRequest struct { - Meta EventMeta `json:"meta"` + Meta HookMeta `json:"meta"` Context *TurnContext `json:"context,omitempty"` Model string `json:"model"` Messages []providers.Message `json:"messages,omitempty"` @@ -104,7 +105,7 @@ func (r *LLMHookRequest) Clone() *LLMHookRequest { return nil } cloned := *r - cloned.Meta = cloneEventMeta(r.Meta) + cloned.Meta = cloneHookMeta(r.Meta) cloned.Context = cloneTurnContext(r.Context) cloned.Messages = cloneProviderMessages(r.Messages) cloned.Tools = cloneToolDefinitions(r.Tools) @@ -113,7 +114,7 @@ func (r *LLMHookRequest) Clone() *LLMHookRequest { } type LLMHookResponse struct { - Meta EventMeta `json:"meta"` + Meta HookMeta `json:"meta"` Context *TurnContext `json:"context,omitempty"` Model string `json:"model"` Response *providers.LLMResponse `json:"response,omitempty"` @@ -124,14 +125,14 @@ func (r *LLMHookResponse) Clone() *LLMHookResponse { return nil } cloned := *r - cloned.Meta = cloneEventMeta(r.Meta) + cloned.Meta = cloneHookMeta(r.Meta) cloned.Context = cloneTurnContext(r.Context) cloned.Response = cloneLLMResponse(r.Response) return &cloned } type ToolCallHookRequest struct { - Meta EventMeta `json:"meta"` + Meta HookMeta `json:"meta"` Context *TurnContext `json:"context,omitempty"` Tool string `json:"tool"` Arguments map[string]any `json:"arguments,omitempty"` @@ -145,7 +146,7 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest { return nil } cloned := *r - cloned.Meta = cloneEventMeta(r.Meta) + cloned.Meta = cloneHookMeta(r.Meta) cloned.Context = cloneTurnContext(r.Context) cloned.Arguments = cloneStringAnyMap(r.Arguments) cloned.HookResult = cloneToolResult(r.HookResult) @@ -153,7 +154,7 @@ func (r *ToolCallHookRequest) Clone() *ToolCallHookRequest { } type ToolApprovalRequest struct { - Meta EventMeta `json:"meta"` + Meta HookMeta `json:"meta"` Context *TurnContext `json:"context,omitempty"` Tool string `json:"tool"` Arguments map[string]any `json:"arguments,omitempty"` @@ -164,14 +165,14 @@ func (r *ToolApprovalRequest) Clone() *ToolApprovalRequest { return nil } cloned := *r - cloned.Meta = cloneEventMeta(r.Meta) + cloned.Meta = cloneHookMeta(r.Meta) cloned.Context = cloneTurnContext(r.Context) cloned.Arguments = cloneStringAnyMap(r.Arguments) return &cloned } type ToolResultHookResponse struct { - Meta EventMeta `json:"meta"` + Meta HookMeta `json:"meta"` Context *TurnContext `json:"context,omitempty"` Tool string `json:"tool"` Arguments map[string]any `json:"arguments,omitempty"` @@ -184,7 +185,7 @@ func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse { return nil } cloned := *r - cloned.Meta = cloneEventMeta(r.Meta) + cloned.Meta = cloneHookMeta(r.Meta) cloned.Context = cloneTurnContext(r.Context) cloned.Arguments = cloneStringAnyMap(r.Arguments) cloned.Result = cloneToolResult(r.Result) @@ -192,7 +193,7 @@ func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse { } type HookManager struct { - eventBus *EventBus + runtimeEvents runtimeevents.EventChannel observerTimeout time.Duration interceptorTimeout time.Duration approvalTimeout time.Duration @@ -201,28 +202,39 @@ type HookManager struct { hooks map[string]HookRegistration ordered []HookRegistration - sub EventSubscription - done chan struct{} - closeOnce sync.Once + runtimeSub runtimeevents.Subscription + runtimeDone chan struct{} + closeOnce sync.Once } -func NewHookManager(eventBus *EventBus) *HookManager { +func NewHookManager(runtimeEvents runtimeevents.EventChannel) *HookManager { hm := &HookManager{ - eventBus: eventBus, + runtimeEvents: runtimeEvents, observerTimeout: defaultHookObserverTimeout, interceptorTimeout: defaultHookInterceptorTimeout, approvalTimeout: defaultHookApprovalTimeout, hooks: make(map[string]HookRegistration), - done: make(chan struct{}), + runtimeDone: make(chan struct{}), } - if eventBus == nil { - close(hm.done) - return hm + if runtimeEvents != nil { + sub, ch, err := runtimeEvents.SubscribeChan(context.Background(), runtimeevents.SubscribeOptions{ + Name: "hook-manager-observer", + Buffer: hookObserverBufferSize, + }) + if err != nil { + logger.WarnCF("hooks", "Failed to subscribe runtime events for hooks", map[string]any{ + "error": err.Error(), + }) + close(hm.runtimeDone) + } else { + hm.runtimeSub = sub + go hm.dispatchRuntimeEvents(ch) + } + } else { + close(hm.runtimeDone) } - hm.sub = eventBus.Subscribe(hookObserverBufferSize) - go hm.dispatchEvents() return hm } @@ -232,10 +244,14 @@ func (hm *HookManager) Close() { } hm.closeOnce.Do(func() { - if hm.eventBus != nil { - hm.eventBus.Unsubscribe(hm.sub.ID) + if hm.runtimeSub != nil { + if err := hm.runtimeSub.Close(); err != nil { + logger.WarnCF("hooks", "Failed to close runtime event hook subscription", map[string]any{ + "error": err.Error(), + }) + } } - <-hm.done + <-hm.runtimeDone hm.closeAllHooks() }) } @@ -292,16 +308,16 @@ func (hm *HookManager) Unmount(name string) { hm.rebuildOrdered() } -func (hm *HookManager) dispatchEvents() { - defer close(hm.done) +func (hm *HookManager) dispatchRuntimeEvents(ch <-chan runtimeevents.Event) { + defer close(hm.runtimeDone) - for evt := range hm.sub.C { + for evt := range ch { for _, reg := range hm.snapshotHooks() { - observer, ok := reg.Hook.(EventObserver) + observer, ok := reg.Hook.(RuntimeEventObserver) if !ok { continue } - hm.runObserver(reg.Name, observer, evt) + hm.runRuntimeObserver(reg.Name, observer, evt) } } } @@ -581,26 +597,30 @@ func (hm *HookManager) closeAllHooks() { hm.ordered = nil } -func (hm *HookManager) runObserver(name string, observer EventObserver, evt Event) { +func (hm *HookManager) runRuntimeObserver( + name string, + observer RuntimeEventObserver, + evt runtimeevents.Event, +) { ctx, cancel := context.WithTimeout(context.Background(), hm.observerTimeout) defer cancel() done := make(chan error, 1) go func() { - done <- observer.OnEvent(ctx, evt) + done <- observer.OnRuntimeEvent(ctx, evt) }() select { case err := <-done: if err != nil { - logger.WarnCF("hooks", "Event observer failed", map[string]any{ + logger.WarnCF("hooks", "Runtime event observer failed", map[string]any{ "hook": name, "event": evt.Kind.String(), "error": err.Error(), }) } case <-ctx.Done(): - logger.WarnCF("hooks", "Event observer timed out", map[string]any{ + logger.WarnCF("hooks", "Runtime event observer timed out", map[string]any{ "hook": name, "event": evt.Kind.String(), "timeout_ms": hm.observerTimeout.Milliseconds(), diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index aa52bf2d5..4deef38c7 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" @@ -111,14 +112,14 @@ func (p *llmHookTestProvider) GetDefaultModel() string { } type llmObserverHook struct { - eventCh chan Event + eventCh chan runtimeevents.Event lastInbound *bus.InboundContext lastRoute *routing.ResolvedRoute lastScope *session.SessionScope } -func (h *llmObserverHook) OnEvent(ctx context.Context, evt Event) error { - if evt.Kind == EventKindTurnEnd { +func (h *llmObserverHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + if evt.Kind == runtimeevents.KindAgentTurnEnd { select { case h.eventCh <- evt: default: @@ -150,6 +151,20 @@ func (h *llmObserverHook) AfterLLM( return next, HookDecision{Action: HookActionModify}, nil } +type dualRuntimeObserverHook struct { + runtimeCh chan runtimeevents.Event +} + +func (h *dualRuntimeObserverHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error { + if evt.Kind == runtimeevents.KindAgentTurnEnd { + select { + case h.runtimeCh <- evt: + default: + } + } + return nil +} + type llmSystemRewriteHook struct{} func (h *llmSystemRewriteHook) BeforeLLM( @@ -417,7 +432,7 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { al, agent, cleanup := newHookTestLoop(t, provider) defer cleanup() - hook := &llmObserverHook{eventCh: make(chan Event, 1)} + hook := &llmObserverHook{eventCh: make(chan runtimeevents.Event, 1)} if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil { t.Fatalf("MountHook failed: %v", err) } @@ -481,30 +496,80 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { select { case evt := <-hook.eventCh: - if evt.Kind != EventKindTurnEnd { + if evt.Kind != runtimeevents.KindAgentTurnEnd { t.Fatalf("expected turn end event, got %v", evt.Kind) } - if evt.Context == nil || evt.Context.Inbound == nil { - t.Fatal("expected observer event to carry inbound context") - } - if evt.Context.Route == nil || evt.Context.Route.AgentID != "main" { - t.Fatalf("expected observer event to carry route context, got %+v", evt.Context.Route) - } - if evt.Context.Scope == nil || evt.Context.Scope.Values["sender"] != "hook-user" { - t.Fatalf("expected observer event to carry session scope, got %+v", evt.Context.Scope) + if evt.Scope.AgentID != "main" || + evt.Scope.SessionKey != "session-1" || + evt.Scope.Channel != "cli" || + evt.Scope.ChatID != "direct" || + evt.Scope.SenderID != "hook-user" { + t.Fatalf("runtime observer scope = %+v", evt.Scope) } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for hook observer event") } } +func TestAgentLoop_Hooks_RuntimeObserverReceivesEvents(t *testing.T) { + provider := &llmHookTestProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &dualRuntimeObserverHook{ + runtimeCh: make(chan runtimeevents.Event, 1), + } + if err := al.MountHook(NamedHook("runtime-observer", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "hello", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + InboundContext: &bus.InboundContext{ + Channel: "cli", + Account: "default", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + MessageID: "msg-1", + }, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + if resp != "provider content" { + t.Fatalf("expected provider content, got %q", resp) + } + + select { + case evt := <-hook.runtimeCh: + if evt.Kind != runtimeevents.KindAgentTurnEnd { + t.Fatalf("runtime observer kind = %q", evt.Kind) + } + if evt.Scope.SessionKey != "session-1" || + evt.Scope.Channel != "cli" || + evt.Scope.ChatID != "direct" || + evt.Scope.MessageID != "msg-1" { + t.Fatalf("runtime observer scope = %+v", evt.Scope) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for runtime observer event") + } +} + func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) { provider := &llmHookTestProvider{} al, agent, cleanup := newHookTestLoop(t, provider) defer cleanup() useTestSideQuestionProvider(al, provider) - hook := &llmObserverHook{eventCh: make(chan Event, 1)} + hook := &llmObserverHook{eventCh: make(chan runtimeevents.Event, 1)} if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil { t.Fatalf("MountHook failed: %v", err) } @@ -800,8 +865,13 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ SessionKey: "session-1", @@ -820,8 +890,8 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) { t.Fatalf("expected %q, got %q", expected, resp) } - events := collectEventStream(sub.C) - skippedEvt, ok := findEvent(events, EventKindToolExecSkipped) + events := collectRuntimeEventStream(runtimeCh) + skippedEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecSkipped) if !ok { t.Fatal("expected tool skipped event") } @@ -876,8 +946,13 @@ func TestAgentLoop_Hooks_ToolRespondAction(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecEnd, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ SessionKey: "session-1", @@ -899,8 +974,8 @@ func TestAgentLoop_Hooks_ToolRespondAction(t *testing.T) { } // Verify event stream has ToolExecEnd, not actual tool execution - events := collectEventStream(sub.C) - endEvt, ok := findEvent(events, EventKindToolExecEnd) + events := collectRuntimeEventStream(runtimeCh) + endEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecEnd) if !ok { t.Fatal("expected tool exec end event") } @@ -1065,8 +1140,13 @@ func TestAgentLoop_HookRespond_MediaError(t *testing.T) { sendErr: errors.New("channel unavailable"), }) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecEnd, + ) + defer closeRuntimeEvents() _, err := al.runAgentLoop(context.Background(), agent, processOptions{ SessionKey: "session-media-err", @@ -1081,8 +1161,8 @@ func TestAgentLoop_HookRespond_MediaError(t *testing.T) { t.Fatalf("runAgentLoop failed: %v", err) } - events := collectEventStream(sub.C) - endEvt, ok := findEvent(events, EventKindToolExecEnd) + events := collectRuntimeEventStream(runtimeCh) + endEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecEnd) if !ok { t.Fatal("expected ToolExecEnd event") } @@ -1120,8 +1200,13 @@ func TestAgentLoop_HookRespond_BusFallback(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentToolExecEnd, + ) + defer closeRuntimeEvents() resp, err := al.runAgentLoop(context.Background(), agent, processOptions{ SessionKey: "session-bus-fallback", @@ -1136,8 +1221,8 @@ func TestAgentLoop_HookRespond_BusFallback(t *testing.T) { t.Fatalf("runAgentLoop failed: %v", err) } - events := collectEventStream(sub.C) - endEvt, ok := findEvent(events, EventKindToolExecEnd) + events := collectRuntimeEventStream(runtimeCh) + endEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentToolExecEnd) if !ok { t.Fatal("expected ToolExecEnd event") } @@ -1282,8 +1367,13 @@ func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) @@ -1322,9 +1412,9 @@ func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) { t.Fatal("timeout waiting for result") } - events := collectEventStream(sub.C) + events := collectRuntimeEventStream(runtimeCh) - skippedEvts := filterEvents(events, EventKindToolExecSkipped) + skippedEvts := filterRuntimeEvents(events, runtimeevents.KindAgentToolExecSkipped) if len(skippedEvts) < 1 { t.Fatal("expected at least one ToolExecSkipped event after interrupt") } @@ -1362,8 +1452,14 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentToolExecEnd, + runtimeevents.KindAgentToolExecSkipped, + ) + defer closeRuntimeEvents() sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) @@ -1383,14 +1479,14 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) { resultCh <- result{resp: resp, err: err} }() - collectedEvents := make([]Event, 0, 8) + collectedEvents := make([]runtimeevents.Event, 0, 8) steered := false deadline := time.After(3 * time.Second) for !steered { select { - case evt := <-sub.C: + case evt := <-runtimeCh: collectedEvents = append(collectedEvents, evt) - if evt.Kind != EventKindToolExecEnd { + if evt.Kind != runtimeevents.KindAgentToolExecEnd { continue } payload, ok := evt.Payload.(ToolExecEndPayload) @@ -1413,9 +1509,9 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) { t.Fatal("timeout waiting for result") } - events := append(collectedEvents, collectEventStream(sub.C)...) + events := append(collectedEvents, collectRuntimeEventStream(runtimeCh)...) - skippedEvts := filterEvents(events, EventKindToolExecSkipped) + skippedEvts := filterRuntimeEvents(events, runtimeevents.KindAgentToolExecSkipped) if len(skippedEvts) < 1 { t.Fatal("expected at least one ToolExecSkipped event after steering") } @@ -1480,13 +1576,3 @@ func TestCloneStringAnyMap_EmptyMapReturnsNonNil(t *testing.T) { } }) } - -func filterEvents(events []Event, kind EventKind) []Event { - var result []Event - for _, evt := range events { - if evt.Kind == kind { - result = append(result, evt) - } - } - return result -} diff --git a/pkg/agent/interfaces/interfaces.go b/pkg/agent/interfaces/interfaces.go index bdf483e20..2efec05e1 100644 --- a/pkg/agent/interfaces/interfaces.go +++ b/pkg/agent/interfaces/interfaces.go @@ -44,4 +44,11 @@ type ChannelManager interface { // SendPlaceholder sends a placeholder message (e.g., for audio transcription). SendPlaceholder(ctx context.Context, channel, chatID string) bool + + // DismissToolFeedback clears any tracked tool feedback animation for the + // given channel/chat. Call this when a turn ends without a final response + // (e.g., ResponseHandled tools) to avoid orphaned animation goroutines. + // outboundCtx carries topic/thread info needed for channels that use + // scoped tracker keys (e.g., Telegram forum topics); may be nil. + DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext) } diff --git a/pkg/agent/llm_media.go b/pkg/agent/llm_media.go index eb1908777..31692174b 100644 --- a/pkg/agent/llm_media.go +++ b/pkg/agent/llm_media.go @@ -56,5 +56,12 @@ func isVisionUnsupportedError(err error) bool { return true } + // DeepSeek and other strict providers reject the image_url field at the + // JSON schema level with an "unknown variant" error rather than a semantic + // "not supported" message. + if strings.Contains(msg, "unknown variant") && strings.Contains(msg, "image_url") { + return true + } + return false } diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 9935f2c9e..0f71c7432 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -10,6 +10,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" @@ -72,7 +73,7 @@ toolLoop: }) al.emitEvent( - EventKindToolExecStart, + runtimeevents.KindAgentToolExecStart, ts.eventMeta("runTurn", "turn.tool.start"), ToolExecStartPayload{ Tool: toolName, @@ -191,7 +192,7 @@ toolLoop: } al.emitEvent( - EventKindToolExecEnd, + runtimeevents.KindAgentToolExecEnd, ts.eventMeta("runTurn", "turn.tool.end"), ToolExecEndPayload{ Tool: toolName, @@ -237,7 +238,7 @@ toolLoop: for j := i + 1; j < len(normalizedToolCalls); j++ { skippedTC := normalizedToolCalls[j] al.emitEvent( - EventKindToolExecSkipped, + runtimeevents.KindAgentToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: skippedTC.Name, @@ -284,7 +285,7 @@ toolLoop: exec.allResponsesHandled = false denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) al.emitEvent( - EventKindToolExecSkipped, + runtimeevents.KindAgentToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: toolName, @@ -323,7 +324,7 @@ toolLoop: exec.allResponsesHandled = false denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) al.emitEvent( - EventKindToolExecSkipped, + runtimeevents.KindAgentToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: toolName, @@ -353,7 +354,7 @@ toolLoop: "iteration": iteration, }) al.emitEvent( - EventKindToolExecStart, + runtimeevents.KindAgentToolExecStart, ts.eventMeta("runTurn", "turn.tool.start"), ToolExecStartPayload{ Tool: toolName, @@ -401,7 +402,7 @@ toolLoop: "channel": ts.channel, }) al.emitEvent( - EventKindFollowUpQueued, + runtimeevents.KindAgentFollowUpQueued, ts.scope.meta(iteration, "runTurn", "turn.follow_up.queued"), FollowUpQueuedPayload{ SourceTool: asyncToolName, @@ -567,7 +568,7 @@ toolLoop: toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) } al.emitEvent( - EventKindToolExecEnd, + runtimeevents.KindAgentToolExecEnd, ts.eventMeta("runTurn", "turn.tool.end"), ToolExecEndPayload{ Tool: toolName, @@ -612,7 +613,7 @@ toolLoop: for j := i + 1; j < len(normalizedToolCalls); j++ { skippedTC := normalizedToolCalls[j] al.emitEvent( - EventKindToolExecSkipped, + runtimeevents.KindAgentToolExecSkipped, ts.eventMeta("runTurn", "turn.tool.skipped"), ToolExecSkippedPayload{ Tool: skippedTC.Name, @@ -704,6 +705,9 @@ toolLoop: } ts.setPhase(TurnPhaseCompleted) ts.setFinalContent("") + if al.channelManager != nil && ts.channel != "" { + al.channelManager.DismissToolFeedback(ctx, ts.channel, ts.chatID, ts.opts.InboundContext) + } logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", map[string]any{ "agent_id": ts.agent.ID, diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go index a2be6f65b..1f407825e 100644 --- a/pkg/agent/pipeline_finalize.go +++ b/pkg/agent/pipeline_finalize.go @@ -6,6 +6,7 @@ import ( "context" "github.com/sipeed/picoclaw/pkg/bus" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -50,7 +51,7 @@ func (p *Pipeline) Finalize( ts.ingestMessage(turnCtx, al, finalMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { al.emitEvent( - EventKindError, + runtimeevents.KindAgentError, ts.eventMeta("runTurn", "turn.error"), ErrorPayload{ Stage: "session_save", diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index 6bf55fa39..496fcd7e4 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -11,6 +11,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -113,7 +114,7 @@ func (p *Pipeline) CallLLM( } al.emitEvent( - EventKindLLMRequest, + runtimeevents.KindAgentLLMRequest, ts.eventMeta("runTurn", "turn.llm.request"), LLMRequestPayload{ Model: exec.llmModel, @@ -184,7 +185,14 @@ func (p *Pipeline) CallLLM( // Retry loop var err error - maxRetries := 2 + maxRetries := p.Cfg.Agents.Defaults.MaxLLMRetries + if maxRetries <= 0 { + maxRetries = 2 + } + backoffSecs := p.Cfg.Agents.Defaults.LLMRetryBackoffSecs + if backoffSecs <= 0 { + backoffSecs = 2 + } for retry := 0; retry <= maxRetries; retry++ { exec.response, err = callLLM(exec.callMessages, exec.providerToolDefs) if err == nil { @@ -199,7 +207,7 @@ func (p *Pipeline) CallLLM( // Retry without media if vision is unsupported if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { al.emitEvent( - EventKindLLMRetry, + runtimeevents.KindAgentLLMRetry, ts.eventMeta("runTurn", "turn.llm.retry"), LLMRetryPayload{ Attempt: retry + 1, @@ -232,6 +240,15 @@ func (p *Pipeline) CallLLM( strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "timeout exceeded") + isNetworkError := !isTimeoutError && (strings.Contains(errMsg, "connection reset") || + strings.Contains(errMsg, "connection refused") || + strings.Contains(errMsg, "broken pipe") || + strings.Contains(errMsg, "no such host") || + strings.Contains(errMsg, "network is unreachable") || + strings.Contains(errMsg, "read tcp") || + strings.Contains(errMsg, "write tcp") || + strings.Contains(errMsg, "eof")) + isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || strings.Contains(errMsg, "context window") || strings.Contains(errMsg, "context_window") || @@ -244,9 +261,9 @@ func (p *Pipeline) CallLLM( strings.Contains(errMsg, "request too large")) if isTimeoutError && retry < maxRetries { - backoff := time.Duration(retry+1) * 5 * time.Second + backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second al.emitEvent( - EventKindLLMRetry, + runtimeevents.KindAgentLLMRetry, ts.eventMeta("runTurn", "turn.llm.retry"), LLMRetryPayload{ Attempt: retry + 1, @@ -272,9 +289,38 @@ func (p *Pipeline) CallLLM( continue } + if isNetworkError && retry < maxRetries { + backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second + al.emitEvent( + runtimeevents.KindAgentLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "network", + Error: err.Error(), + Backoff: backoff, + }, + ) + logger.WarnCF("agent", "Network error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { + if ts.hardAbortRequested() { + _ = ts.requestHardAbort() + return ControlBreak, nil + } + err = sleepErr + break + } + continue + } + if isContextError && retry < maxRetries && !ts.opts.NoHistory { al.emitEvent( - EventKindLLMRetry, + runtimeevents.KindAgentLLMRetry, ts.eventMeta("runTurn", "turn.llm.retry"), LLMRetryPayload{ Attempt: retry + 1, @@ -333,7 +379,7 @@ func (p *Pipeline) CallLLM( if err != nil { al.emitEvent( - EventKindError, + runtimeevents.KindAgentError, ts.eventMeta("runTurn", "turn.error"), ErrorPayload{ Stage: "llm", @@ -397,7 +443,7 @@ func (p *Pipeline) CallLLM( ) } al.emitEvent( - EventKindLLMResponse, + runtimeevents.KindAgentLLMResponse, ts.eventMeta("runTurn", "turn.llm.response"), LLMResponsePayload{ ContentLen: len(exec.response.Content), diff --git a/pkg/agent/runtime_event_logger.go b/pkg/agent/runtime_event_logger.go new file mode 100644 index 000000000..1035ffe35 --- /dev/null +++ b/pkg/agent/runtime_event_logger.go @@ -0,0 +1,408 @@ +package agent + +import ( + "context" + "fmt" + "path" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + runtimeEventLoggerBuffer = 256 + runtimeEventLoggerDrainTimeout = 2 * time.Second +) + +type runtimeEventLogger struct { + mu sync.RWMutex + cfg config.EventLoggingConfig +} + +func (al *AgentLoop) refreshRuntimeEventLogger(cfg *config.Config) { + if al == nil { + return + } + logCfg := config.EffectiveEventLoggingConfig(cfg) + + al.runtimeEventLogMu.Lock() + if !logCfg.Enabled { + oldSub := al.runtimeEventLogSub + al.runtimeEventLogger = nil + al.runtimeEventLogSub = nil + al.runtimeEventLogMu.Unlock() + closeRuntimeEventLoggerSubscription(oldSub) + return + } + + if al.runtimeEventLogger != nil && al.runtimeEventLogSub != nil { + al.runtimeEventLogger.updateConfig(logCfg) + al.runtimeEventLogMu.Unlock() + return + } + al.runtimeEventLogMu.Unlock() + + eventLogger := newRuntimeEventLoggerFromConfig(logCfg) + sub, err := eventLogger.subscribe(context.Background(), al.runtimeEvents) + if err != nil { + logger.WarnCF("events", "Failed to subscribe runtime event logger", map[string]any{"error": err.Error()}) + return + } + + al.runtimeEventLogMu.Lock() + oldSub := al.runtimeEventLogSub + al.runtimeEventLogger = eventLogger + al.runtimeEventLogSub = sub + al.runtimeEventLogMu.Unlock() + closeRuntimeEventLoggerSubscription(oldSub) +} + +func (al *AgentLoop) closeRuntimeEventLogger() { + if al == nil { + return + } + al.runtimeEventLogMu.Lock() + oldSub := al.runtimeEventLogSub + al.runtimeEventLogger = nil + al.runtimeEventLogSub = nil + al.runtimeEventLogMu.Unlock() + closeRuntimeEventLoggerSubscription(oldSub) +} + +func closeRuntimeEventLoggerSubscription(sub runtimeevents.Subscription) { + if sub == nil { + return + } + if err := sub.Close(); err != nil { + logger.WarnCF("events", "Failed to close runtime event logger subscription", map[string]any{ + "error": err.Error(), + }) + } + + timer := time.NewTimer(runtimeEventLoggerDrainTimeout) + defer timer.Stop() + select { + case <-sub.Done(): + case <-timer.C: + logger.WarnCF("events", "Timed out waiting for runtime event logger to drain", map[string]any{ + "timeout": runtimeEventLoggerDrainTimeout.String(), + }) + } +} + +func newRuntimeEventLogger(cfg *config.Config) *runtimeEventLogger { + logCfg := config.EffectiveEventLoggingConfig(cfg) + if !logCfg.Enabled { + return nil + } + return newRuntimeEventLoggerFromConfig(logCfg) +} + +func newRuntimeEventLoggerFromConfig(logCfg config.EventLoggingConfig) *runtimeEventLogger { + return &runtimeEventLogger{cfg: logCfg} +} + +func (l *runtimeEventLogger) updateConfig(cfg config.EventLoggingConfig) { + if l == nil { + return + } + l.mu.Lock() + l.cfg = cfg + l.mu.Unlock() +} + +func (l *runtimeEventLogger) configSnapshot() config.EventLoggingConfig { + if l == nil { + return config.EventLoggingConfig{} + } + l.mu.RLock() + defer l.mu.RUnlock() + return l.cfg +} + +func (l *runtimeEventLogger) subscribe( + ctx context.Context, + eventBus runtimeevents.Bus, +) (runtimeevents.Subscription, error) { + if l == nil || eventBus == nil { + return nil, nil + } + return eventBus.Channel().Subscribe(ctx, runtimeevents.SubscribeOptions{ + Name: "runtime-event-logger", + Buffer: runtimeEventLoggerBuffer, + Concurrency: runtimeevents.Locked, + Backpressure: runtimeevents.DropNewest, + PanicPolicy: runtimeevents.RecoverAndLog, + }, l.handle) +} + +func (l *runtimeEventLogger) handle(_ context.Context, evt runtimeevents.Event) error { + if l == nil || !l.shouldLog(evt) { + return nil + } + + fields := runtimeEventLogFields(evt) + if l.configSnapshot().IncludePayload && evt.Payload != nil { + fields["payload"] = evt.Payload + } + + logRuntimeEvent(evt, fields) + return nil +} + +func (l *runtimeEventLogger) shouldLog(evt runtimeevents.Event) bool { + if l == nil { + return false + } + cfg := l.configSnapshot() + if !cfg.Enabled { + return false + } + if runtimeEventSeverityRank(evt.Severity) < runtimeEventSeverityRank(parseRuntimeEventSeverity(cfg.MinSeverity)) { + return false + } + + kind := evt.Kind.String() + if !matchAnyRuntimeEventPattern(cfg.Include, kind, true) { + return false + } + return !matchAnyRuntimeEventPattern(cfg.Exclude, kind, false) +} + +func logRuntimeEvent(evt runtimeevents.Event, fields map[string]any) { + message := fmt.Sprintf("Runtime event: %s", evt.Kind.String()) + switch normalizeRuntimeEventSeverity(evt.Severity) { + case runtimeevents.SeverityDebug: + logger.DebugCF("events", message, fields) + case runtimeevents.SeverityWarn: + logger.WarnCF("events", message, fields) + case runtimeevents.SeverityError: + logger.ErrorCF("events", message, fields) + default: + logger.InfoCF("events", message, fields) + } +} + +func runtimeEventLogFields(evt runtimeevents.Event) map[string]any { + fields := map[string]any{ + "event_id": evt.ID, + "event_kind": evt.Kind.String(), + "severity": string(normalizeRuntimeEventSeverity(evt.Severity)), + } + if !evt.Time.IsZero() { + fields["event_time"] = evt.Time.Format(time.RFC3339Nano) + } + appendRuntimeEventSourceFields(fields, evt.Source) + appendRuntimeEventScopeFields(fields, evt.Scope) + appendRuntimeEventCorrelationFields(fields, evt.Correlation) + appendRuntimeEventAttrs(fields, evt.Attrs) + appendRuntimeEventPayloadSummary(fields, evt.Payload) + return fields +} + +func appendRuntimeEventSourceFields(fields map[string]any, source runtimeevents.Source) { + if source.Component != "" { + fields["source_component"] = source.Component + } + if source.Name != "" { + fields["source_name"] = source.Name + } +} + +func appendRuntimeEventScopeFields(fields map[string]any, scope runtimeevents.Scope) { + setStringField(fields, "runtime_id", scope.RuntimeID) + setStringField(fields, "agent_id", scope.AgentID) + setStringField(fields, "session_key", scope.SessionKey) + setStringField(fields, "turn_id", scope.TurnID) + setStringField(fields, "channel", scope.Channel) + setStringField(fields, "account", scope.Account) + setStringField(fields, "chat_id", scope.ChatID) + setStringField(fields, "topic_id", scope.TopicID) + setStringField(fields, "space_id", scope.SpaceID) + setStringField(fields, "space_type", scope.SpaceType) + setStringField(fields, "chat_type", scope.ChatType) + setStringField(fields, "sender_id", scope.SenderID) + setStringField(fields, "message_id", scope.MessageID) +} + +func appendRuntimeEventCorrelationFields(fields map[string]any, correlation runtimeevents.Correlation) { + setStringField(fields, "trace_id", correlation.TraceID) + setStringField(fields, "parent_turn_id", correlation.ParentTurnID) + setStringField(fields, "request_id", correlation.RequestID) + setStringField(fields, "reply_to_id", correlation.ReplyToID) +} + +func appendRuntimeEventAttrs(fields map[string]any, attrs map[string]any) { + for key, value := range attrs { + if key == "" || value == nil { + continue + } + if _, exists := fields[key]; exists { + fields["attr_"+key] = value + continue + } + fields[key] = value + } +} + +func appendRuntimeEventPayloadSummary(fields map[string]any, payload any) { + switch payload := payload.(type) { + case TurnStartPayload: + fields["user_len"] = len(payload.UserMessage) + fields["media_count"] = payload.MediaCount + case TurnEndPayload: + fields["status"] = payload.Status + fields["iterations_total"] = payload.Iterations + fields["duration_ms"] = payload.Duration.Milliseconds() + fields["final_len"] = payload.FinalContentLen + case LLMRequestPayload: + fields["model"] = payload.Model + fields["messages"] = payload.MessagesCount + fields["tools"] = payload.ToolsCount + fields["max_tokens"] = payload.MaxTokens + case LLMDeltaPayload: + fields["content_delta_len"] = payload.ContentDeltaLen + fields["reasoning_delta_len"] = payload.ReasoningDeltaLen + case LLMResponsePayload: + fields["content_len"] = payload.ContentLen + fields["tool_calls"] = payload.ToolCalls + fields["has_reasoning"] = payload.HasReasoning + case LLMRetryPayload: + fields["attempt"] = payload.Attempt + fields["max_retries"] = payload.MaxRetries + fields["reason"] = payload.Reason + fields["error"] = payload.Error + fields["backoff_ms"] = payload.Backoff.Milliseconds() + case ContextCompressPayload: + fields["reason"] = payload.Reason + fields["dropped_messages"] = payload.DroppedMessages + fields["remaining_messages"] = payload.RemainingMessages + case SessionSummarizePayload: + fields["summarized_messages"] = payload.SummarizedMessages + fields["kept_messages"] = payload.KeptMessages + fields["summary_len"] = payload.SummaryLen + fields["omitted_oversized"] = payload.OmittedOversized + case ToolExecStartPayload: + fields["tool"] = payload.Tool + fields["args_count"] = len(payload.Arguments) + case ToolExecEndPayload: + fields["tool"] = payload.Tool + fields["duration_ms"] = payload.Duration.Milliseconds() + fields["for_llm_len"] = payload.ForLLMLen + fields["for_user_len"] = payload.ForUserLen + fields["is_error"] = payload.IsError + fields["async"] = payload.Async + case ToolExecSkippedPayload: + fields["tool"] = payload.Tool + fields["reason"] = payload.Reason + case SteeringInjectedPayload: + fields["count"] = payload.Count + fields["total_content_len"] = payload.TotalContentLen + case FollowUpQueuedPayload: + fields["source_tool"] = payload.SourceTool + fields["content_len"] = payload.ContentLen + case InterruptReceivedPayload: + fields["interrupt_kind"] = payload.Kind + fields["role"] = payload.Role + fields["content_len"] = payload.ContentLen + fields["queue_depth"] = payload.QueueDepth + fields["hint_len"] = payload.HintLen + case SubTurnSpawnPayload: + fields["child_agent_id"] = payload.AgentID + fields["label"] = payload.Label + case SubTurnEndPayload: + fields["child_agent_id"] = payload.AgentID + fields["status"] = payload.Status + case SubTurnResultDeliveredPayload: + fields["target_channel"] = payload.TargetChannel + fields["target_chat_id"] = payload.TargetChatID + fields["content_len"] = payload.ContentLen + case SubTurnOrphanPayload: + fields["parent_turn_id"] = payload.ParentTurnID + fields["child_turn_id"] = payload.ChildTurnID + fields["reason"] = payload.Reason + case ErrorPayload: + fields["stage"] = payload.Stage + fields["error"] = payload.Message + } +} + +func setStringField(fields map[string]any, key, value string) { + if value != "" { + fields[key] = value + } +} + +func matchAnyRuntimeEventPattern(patterns []string, kind string, emptyMatches bool) bool { + if len(patterns) == 0 { + return emptyMatches + } + for _, pattern := range patterns { + if matchRuntimeEventPattern(pattern, kind) { + return true + } + } + return false +} + +func matchRuntimeEventPattern(pattern, kind string) bool { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return false + } + if pattern == "*" { + return true + } + if strings.HasSuffix(pattern, ".*") { + return strings.HasPrefix(kind, strings.TrimSuffix(pattern, "*")) + } + matched, err := path.Match(pattern, kind) + if err == nil { + return matched + } + return pattern == kind +} + +func parseRuntimeEventSeverity(severity string) runtimeevents.Severity { + switch strings.ToLower(strings.TrimSpace(severity)) { + case "debug": + return runtimeevents.SeverityDebug + case "warn", "warning": + return runtimeevents.SeverityWarn + case "error": + return runtimeevents.SeverityError + default: + return runtimeevents.SeverityInfo + } +} + +func normalizeRuntimeEventSeverity(severity runtimeevents.Severity) runtimeevents.Severity { + switch severity { + case runtimeevents.SeverityDebug, + runtimeevents.SeverityInfo, + runtimeevents.SeverityWarn, + runtimeevents.SeverityError: + return severity + default: + return runtimeevents.SeverityInfo + } +} + +func runtimeEventSeverityRank(severity runtimeevents.Severity) int { + switch normalizeRuntimeEventSeverity(severity) { + case runtimeevents.SeverityDebug: + return 0 + case runtimeevents.SeverityInfo: + return 1 + case runtimeevents.SeverityWarn: + return 2 + case runtimeevents.SeverityError: + return 3 + default: + return 1 + } +} diff --git a/pkg/agent/runtime_event_logger_test.go b/pkg/agent/runtime_event_logger_test.go new file mode 100644 index 000000000..1c95b365c --- /dev/null +++ b/pkg/agent/runtime_event_logger_test.go @@ -0,0 +1,259 @@ +package agent + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func TestRuntimeEventLoggerFiltering(t *testing.T) { + cfg := config.DefaultConfig() + eventLogger := newRuntimeEventLogger(cfg) + if eventLogger == nil { + t.Fatal("default runtime event logger is nil") + } + + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindAgentTurnStart, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("default config should log agent events") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindChannelLifecycleStarted, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("default config should not log non-agent events") + } + + cfg.Events.Logging.Include = []string{"*"} + cfg.Events.Logging.Exclude = []string{"mcp.*"} + eventLogger = newRuntimeEventLogger(cfg) + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReady, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("include * should log gateway events") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindMCPServerConnected, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("exclude mcp.* should suppress MCP events") + } + + cfg.Events.Logging.Exclude = nil + cfg.Events.Logging.MinSeverity = "warn" + eventLogger = newRuntimeEventLogger(cfg) + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReady, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("min severity warn should suppress info events") + } + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReloadFailed, + Severity: runtimeevents.SeverityError, + }) { + t.Fatal("min severity warn should allow error events") + } + + cfg.Events.Logging.Enabled = false + if newRuntimeEventLogger(cfg) != nil { + t.Fatal("disabled config should not create runtime event logger") + } +} + +func TestRuntimeEventLogFieldsSummarizeAgentPayload(t *testing.T) { + fields := runtimeEventLogFields(runtimeevents.Event{ + ID: "evt-test", + Kind: runtimeevents.KindAgentToolExecStart, + Severity: runtimeevents.SeverityInfo, + Source: runtimeevents.Source{ + Component: "agent", + Name: "main", + }, + Scope: runtimeevents.Scope{ + AgentID: "main", + SessionKey: "session-1", + TurnID: "turn-1", + }, + Payload: ToolExecStartPayload{ + Tool: "exec", + Arguments: map[string]any{ + "secret": "should-not-be-logged-by-default", + }, + }, + }) + + if fields["event_id"] != "evt-test" || fields["source_component"] != "agent" { + t.Fatalf("missing common event fields: %#v", fields) + } + if fields["tool"] != "exec" || fields["args_count"] != 1 { + t.Fatalf("missing safe agent payload summary fields: %#v", fields) + } + if _, ok := fields["payload"]; ok { + t.Fatalf("raw payload should not be included by runtimeEventLogFields: %#v", fields) + } +} + +func TestRuntimeEventLogFieldsIncludeSafeAttrs(t *testing.T) { + fields := runtimeEventLogFields(runtimeevents.Event{ + ID: "evt-gateway", + Kind: runtimeevents.KindGatewayReady, + Severity: runtimeevents.SeverityInfo, + Attrs: map[string]any{ + "duration_ms": 42, + "error": "startup failed", + "event_kind": "conflict", + }, + }) + + if fields["duration_ms"] != 42 || fields["error"] != "startup failed" { + t.Fatalf("missing safe attrs: %#v", fields) + } + if fields["event_kind"] != runtimeevents.KindGatewayReady.String() { + t.Fatalf("event_kind overwritten by attrs: %#v", fields) + } + if fields["attr_event_kind"] != "conflict" { + t.Fatalf("conflicting attr not preserved with prefix: %#v", fields) + } + if _, ok := fields["payload"]; ok { + t.Fatalf("raw payload should not be included by runtimeEventLogFields: %#v", fields) + } +} + +func runtimeEventLoggerStateForTest( + al *AgentLoop, +) (*runtimeEventLogger, runtimeevents.Subscription) { + al.runtimeEventLogMu.RLock() + defer al.runtimeEventLogMu.RUnlock() + return al.runtimeEventLogger, al.runtimeEventLogSub +} + +func TestReloadProviderAndConfigRefreshesRuntimeEventLogger(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Events.Logging.Include = []string{"agent.*"} + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + defer al.Close() + + eventLogger, logSub := runtimeEventLoggerStateForTest(al) + if eventLogger == nil || logSub == nil { + t.Fatal("expected initial runtime event logger subscription") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReloadCompleted, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("initial agent-only logging should not log gateway reload events") + } + + reloaded := config.DefaultConfig() + reloaded.Agents.Defaults.Workspace = cfg.Agents.Defaults.Workspace + reloaded.Events.Logging.Include = []string{"gateway.*"} + if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloaded); err != nil { + t.Fatalf("ReloadProviderAndConfig() error = %v", err) + } + + eventLogger, logSub = runtimeEventLoggerStateForTest(al) + if eventLogger == nil || logSub == nil { + t.Fatal("expected runtime event logger subscription after reload") + } + if !eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindGatewayReloadCompleted, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("reloaded gateway logging should log gateway reload events") + } + if eventLogger.shouldLog(runtimeevents.Event{ + Kind: runtimeevents.KindAgentTurnStart, + Severity: runtimeevents.SeverityInfo, + }) { + t.Fatal("reloaded gateway-only logging should not log agent events") + } + + disabled := config.DefaultConfig() + disabled.Agents.Defaults.Workspace = cfg.Agents.Defaults.Workspace + disabled.Events.Logging.Enabled = false + if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, disabled); err != nil { + t.Fatalf("ReloadProviderAndConfig() with disabled logging error = %v", err) + } + eventLogger, logSub = runtimeEventLoggerStateForTest(al) + if eventLogger != nil || logSub != nil { + t.Fatal("expected runtime event logger to be disabled after reload") + } +} + +func TestCloseRuntimeEventLoggerSubscriptionWaitsForDrain(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + }() + + var handled atomic.Uint64 + firstStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) + sub, err := eventBus.Channel().Subscribe( + context.Background(), + runtimeevents.SubscribeOptions{ + Name: "runtime-event-logger", + Buffer: 2, + Concurrency: runtimeevents.Locked, + }, + func(context.Context, runtimeevents.Event) error { + if handled.Add(1) == 1 { + close(firstStarted) + <-releaseFirst + } + return nil + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + first := eventBus.Publish(context.Background(), runtimeevents.Event{Kind: runtimeevents.Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + select { + case <-firstStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first handler to start") + } + second := eventBus.Publish(context.Background(), runtimeevents.Event{Kind: runtimeevents.Kind("test.second")}) + if second.Delivered != 1 { + t.Fatalf("second Publish = %+v, want one delivered event", second) + } + + closeReturned := make(chan struct{}) + go func() { + closeRuntimeEventLoggerSubscription(sub) + close(closeReturned) + }() + + select { + case <-closeReturned: + t.Fatal("runtime event logger close returned before buffered events drained") + case <-time.After(50 * time.Millisecond): + } + + close(releaseFirst) + select { + case <-closeReturned: + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event logger close to return") + } + if got := handled.Load(); got != 2 { + t.Fatalf("handled = %d, want 2", got) + } +} diff --git a/pkg/agent/runtime_event_test.go b/pkg/agent/runtime_event_test.go new file mode 100644 index 000000000..162ccf424 --- /dev/null +++ b/pkg/agent/runtime_event_test.go @@ -0,0 +1,103 @@ +package agent + +import ( + "testing" + "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func subscribeRuntimeEventsForTest( + t *testing.T, + al *AgentLoop, + buffer int, + kinds ...runtimeevents.Kind, +) (<-chan runtimeevents.Event, func()) { + t.Helper() + + if al == nil { + t.Fatal("agent loop is nil") + } + channel := al.RuntimeEvents() + if channel == nil { + t.Fatal("runtime event channel is nil") + } + if len(kinds) > 0 { + channel = channel.OfKind(kinds...) + } + sub, ch, err := channel.SubscribeChan( + t.Context(), + runtimeevents.SubscribeOptions{Name: "agent-runtime-test", Buffer: buffer}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + return ch, func() { + if err := sub.Close(); err != nil { + t.Errorf("runtime subscription close failed: %v", err) + } + } +} + +func waitForRuntimeEvent( + t *testing.T, + ch <-chan runtimeevents.Event, + timeout time.Duration, + match func(runtimeevents.Event) bool, +) runtimeevents.Event { + t.Helper() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event stream closed before expected event arrived") + } + if match(evt) { + return evt + } + case <-timer.C: + t.Fatal("timed out waiting for expected runtime event") + } + } +} + +func collectRuntimeEventStream(ch <-chan runtimeevents.Event) []runtimeevents.Event { + var events []runtimeevents.Event + for { + select { + case evt, ok := <-ch: + if !ok { + return events + } + events = append(events, evt) + default: + return events + } + } +} + +func findRuntimeEvent( + events []runtimeevents.Event, + kind runtimeevents.Kind, +) (runtimeevents.Event, bool) { + for _, evt := range events { + if evt.Kind == kind { + return evt, true + } + } + return runtimeevents.Event{}, false +} + +func filterRuntimeEvents(events []runtimeevents.Event, kind runtimeevents.Kind) []runtimeevents.Event { + var filtered []runtimeevents.Event + for _, evt := range events { + if evt.Kind == kind { + filtered = append(filtered, evt) + } + } + return filtered +} diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index 2efa7bbf4..7bddbfc31 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/sipeed/picoclaw/pkg/bus" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" @@ -155,6 +156,18 @@ func (sq *steeringQueue) lenScope(scope string) int { return len(sq.queues[normalizeSteeringScope(scope)]) } +func (sq *steeringQueue) clearScope(scope string) int { + sq.mu.Lock() + defer sq.mu.Unlock() + + scope = normalizeSteeringScope(scope) + count := len(sq.queues[scope]) + if count > 0 { + delete(sq.queues, scope) + } + return count +} + // setMode updates the steering mode. func (sq *steeringQueue) setMode(mode SteeringMode) { sq.mu.Lock() @@ -206,7 +219,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers "scope": normalizeSteeringScope(scope), }) - meta := EventMeta{ + meta := HookMeta{ Source: "Steer", TracePath: "turn.interrupt.received", } @@ -230,7 +243,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers } al.emitEvent( - EventKindInterruptReceived, + runtimeevents.KindAgentInterruptReceived, meta, InterruptReceivedPayload{ Kind: InterruptKindSteering, @@ -289,6 +302,13 @@ func (al *AgentLoop) pendingSteeringCountForScope(scope string) int { return al.steering.lenScope(scope) } +func (al *AgentLoop) clearSteeringMessagesForScope(scope string) int { + if al.steering == nil { + return 0 + } + return al.steering.clearScope(scope) +} + func (al *AgentLoop) continueWithSteeringMessages( ctx context.Context, agent *AgentInstance, @@ -410,7 +430,7 @@ func (al *AgentLoop) InterruptGraceful(hint string) error { } al.emitEvent( - EventKindInterruptReceived, + runtimeevents.KindAgentInterruptReceived, ts.eventMeta("InterruptGraceful", "turn.interrupt.received"), InterruptReceivedPayload{ Kind: InterruptKindGraceful, @@ -438,7 +458,7 @@ func (al *AgentLoop) InterruptHard() error { } al.emitEvent( - EventKindInterruptReceived, + runtimeevents.KindAgentInterruptReceived, ts.eventMeta("InterruptHard", "turn.interrupt.received"), InterruptReceivedPayload{ Kind: InterruptKindHard, @@ -510,6 +530,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error { "initial_history_length": ts.initialHistoryLength, }) + // Cancel the active provider/tool turn contexts immediately so long-running + // execution stops as soon as possible on the root turn. + _ = ts.requestHardAbort() + // IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns // from adding more messages to the session. This prevents race conditions // where rollback happens while children are still writing. diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index bba988672..813013649 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" @@ -839,6 +840,191 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { } } +func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(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, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &lateSteeringProvider{ + firstCallStarted: make(chan struct{}), + releaseFirstCall: make(chan struct{}), + } + al := NewAgentLoop(cfg, msgBus, provider) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + blockerSessionKey := session.BuildOpaqueSessionKey("agent:main:test:blocker") + targetSessionKey := session.BuildOpaqueSessionKey("agent:main:test:target") + blockerCtx := bus.InboundContext{ + Channel: "test", + ChatID: "blocker-chat", + ChatType: "direct", + SenderID: "user1", + } + targetCtx := bus.InboundContext{ + Channel: "test", + ChatID: "target-chat", + ChatType: "direct", + SenderID: "user1", + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: blockerCtx, + Content: "block worker pool", + SessionKey: blockerSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(blocker) error = %v", err) + } + + select { + case <-provider.firstCallStarted: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for blocker turn to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "skip this turn", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(target start) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for { + ts := al.getActiveTurnState(targetSessionKey) + if ts != nil && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for pending placeholder") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "/stop", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + stopSeen := false + for !stopSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "Task stopped. Current task was canceled." { + stopSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for /stop reply") + } + } + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "run this instead", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(targetSessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up to enter scoped steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + close(provider.releaseFirstCall) + + deadline = time.Now().Add(5 * time.Second) + followUpSeen := false + for !followUpSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "continued response" { + followUpSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for queued follow-up continuation") + } + } + } + + deadline = time.Now().Add(2 * time.Second) + for { + if al.GetActiveTurnBySession(targetSessionKey) == nil && + al.pendingSteeringCountForScope(targetSessionKey) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for target session to go idle") + } + time.Sleep(10 * time.Millisecond) + } + + provider.mu.Lock() + calls := provider.calls + secondMessages := append([]providers.Message(nil), provider.secondCallMessages...) + provider.mu.Unlock() + + if calls != 2 { + t.Fatalf("expected 2 provider calls (blocker + continuation), got %d", calls) + } + + foundFollowUp := false + for _, msg := range secondMessages { + if msg.Role == "user" && msg.Content == "run this instead" { + foundFollowUp = true + } + if msg.Role == "user" && msg.Content == "skip this turn" { + t.Fatalf("unexpected canceled message in continuation context: %q", msg.Content) + } + } + if !foundFollowUp { + t.Fatal("expected queued follow-up to be processed after pending stop") + } +} + func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { @@ -1051,16 +1237,16 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { foundResolvedMedia := false for _, msg := range msgs { - if msg.Role != "user" || msg.Content != "describe this image" || len(msg.Media) != 1 { + if msg.Role != "user" || !strings.Contains(msg.Content, "describe this image") { continue } - if strings.HasPrefix(msg.Media[0], "data:image/png;base64,") { + if strings.Contains(msg.Content, "[image:") { foundResolvedMedia = true break } } if !foundResolvedMedia { - t.Fatal("expected continue path to inject steering media into the provider request") + t.Fatal("expected continue path to inject image path tag into the provider request") } defaultAgent := al.registry.GetDefaultAgent() @@ -1134,8 +1320,14 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { al.RegisterTool(tool2) sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentInterruptReceived, + runtimeevents.KindAgentTurnEnd, + ) + defer closeRuntimeEvents() type result struct { resp string @@ -1222,8 +1414,8 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { t.Fatal("expected remaining tool to be marked as skipped after graceful interrupt") } - events := collectEventStream(sub.C) - interruptEvt, ok := findEvent(events, EventKindInterruptReceived) + events := collectRuntimeEventStream(runtimeCh) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) if !ok { t.Fatal("expected interrupt received event") } @@ -1235,7 +1427,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { t.Fatalf("expected graceful interrupt payload, got %q", interruptPayload.Kind) } - turnEndEvt, ok := findEvent(events, EventKindTurnEnd) + turnEndEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentTurnEnd) if !ok { t.Fatal("expected turn end event") } @@ -1299,8 +1491,14 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { } defaultAgent.Sessions.SetHistory(sessionKey, originalHistory) - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentInterruptReceived, + runtimeevents.KindAgentTurnEnd, + ) + defer closeRuntimeEvents() type result struct { resp string @@ -1353,8 +1551,8 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { t.Fatalf("expected history rollback after hard abort, got %#v", finalHistory) } - events := collectEventStream(sub.C) - interruptEvt, ok := findEvent(events, EventKindInterruptReceived) + events := collectRuntimeEventStream(runtimeCh) + interruptEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentInterruptReceived) if !ok { t.Fatal("expected interrupt received event") } @@ -1366,7 +1564,7 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { t.Fatalf("expected hard interrupt payload, got %q", interruptPayload.Kind) } - turnEndEvt, ok := findEvent(events, EventKindTurnEnd) + turnEndEvt, ok := findRuntimeEvent(events, runtimeevents.KindAgentTurnEnd) if !ok { t.Fatal("expected turn end event") } @@ -1379,6 +1577,149 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { } } +func TestAgentLoop_StopCommand_AbortsActiveTurnAndClearsQueuedSteering(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, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "cancel_tool", + Function: &providers.FunctionCall{ + Name: "cancel_tool", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "should not continue", + } + + al := NewAgentLoop(cfg, msgBus, provider) + started := make(chan struct{}) + al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + baseMsg := testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + SessionKey: sessionKey, + }) + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "do work", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(start) error = %v", err) + } + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for interruptible tool to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "follow up after cancel", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(sessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up message to enter steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "/stop", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + select { + case outbound := <-msgBus.OutboundChan(): + want := "Task stopped. \"do work\" was canceled." + if outbound.Content != want { + t.Fatalf("stop reply = %q, want %q", outbound.Content, want) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for /stop reply") + } + + deadline = time.Now().Add(5 * time.Second) + for al.GetActiveTurnBySession(sessionKey) != nil { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for active turn to stop") + } + time.Sleep(10 * time.Millisecond) + } + + if got := al.pendingSteeringCountForScope(sessionKey); got != 0 { + t.Fatalf("expected cleared steering queue, got %d pending message(s)", got) + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected outbound after stop: %q", outbound.Content) + case <-time.After(300 * time.Millisecond): + } + + provider.mu.Lock() + calls := provider.calls + provider.mu.Unlock() + if calls != 1 { + t.Fatalf("expected provider to stop before follow-up turn, got %d calls", calls) + } +} + // capturingMockProvider captures messages sent to Chat for inspection. type capturingMockProvider struct { response string diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 4d824bd3a..86617d02f 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers/messageutil" @@ -173,7 +174,10 @@ type SubTurnConfig struct { // Used by team tool to enforce token limits across all team members. InitialTokenBudget *atomic.Int64 - // Can be extended with temperature, topP, etc. + // TargetAgentID, when set, runs the sub-turn as the specified agent. + // The target agent's workspace, model, tools, and system prompt are used + // instead of the caller's. If empty, the sub-turn runs as the parent agent. + TargetAgentID string } // ====================== Context Keys ====================== @@ -231,6 +235,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn( Critical: cfg.Critical, Timeout: cfg.Timeout, MaxContextRunes: cfg.MaxContextRunes, + TargetAgentID: cfg.TargetAgentID, } return spawnSubTurn(ctx, s.al, parentTS, agentCfg) @@ -313,8 +318,9 @@ func spawnSubTurn( return nil, ErrDepthLimitExceeded } - // 2. Config validation - if cfg.Model == "" { + // 2. Config validation: Model is required unless TargetAgentID is set + // (the target agent provides its own model). + if cfg.Model == "" && cfg.TargetAgentID == "" { return nil, ErrInvalidSubTurnConfig } @@ -332,12 +338,22 @@ func spawnSubTurn( childID := al.generateSubTurnID() - // Get the agent instance from parent, falling back to the default agent. - // Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store - // so that child turns never pollute or persist to the parent's session history. - baseAgent := parentTS.agent - if baseAgent == nil { - baseAgent = al.registry.GetDefaultAgent() + // Resolve the agent instance for the child turn. + // When TargetAgentID is set, look up that agent from the registry so the + // child runs with the target's workspace, model, tools, and system prompt. + // Otherwise fall back to the parent's agent (existing behavior). + var baseAgent *AgentInstance + if cfg.TargetAgentID != "" { + var ok bool + baseAgent, ok = al.registry.GetAgent(cfg.TargetAgentID) + if !ok { + return nil, fmt.Errorf("target agent %q not found in registry", cfg.TargetAgentID) + } + } else { + baseAgent = parentTS.agent + if baseAgent == nil { + baseAgent = al.registry.GetDefaultAgent() + } } if baseAgent == nil { return nil, errors.New("parent turnState has no agent instance") @@ -422,7 +438,7 @@ func spawnSubTurn( parentTS.mu.Unlock() // 6. Emit Spawn event - al.emitEvent(EventKindSubTurnSpawn, + al.emitEvent(runtimeevents.KindAgentSubTurnSpawn, childTS.eventMeta("spawnSubTurn", "subturn.spawn"), SubTurnSpawnPayload{ AgentID: childTS.agentID, @@ -453,7 +469,7 @@ func spawnSubTurn( if err != nil { status = "error" } - al.emitEvent(EventKindSubTurnEnd, + al.emitEvent(runtimeevents.KindAgentSubTurnEnd, childTS.eventMeta("spawnSubTurn", "subturn.end"), SubTurnEndPayload{ AgentID: childTS.agentID, @@ -504,16 +520,16 @@ func spawnSubTurn( // // Delivery behavior: // - If parent turn is still running: attempts to deliver to pendingResults channel -// - If channel is full: emits SubTurnOrphanResultEvent (result is lost from channel but tracked) -// - If parent turn has finished: emits SubTurnOrphanResultEvent (late arrival) +// - If channel is full: emits agent.subturn.orphan (result is lost from channel but tracked) +// - If parent turn has finished: emits agent.subturn.orphan (late arrival) // // Thread safety: // - Reads parent state under lock, then releases lock before channel send // - Small race window exists but is acceptable (worst case: result becomes orphan) // // Event emissions: -// - SubTurnResultDeliveredEvent: successful delivery to channel -// - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full) +// - agent.subturn.result_delivered: successful delivery to channel +// - agent.subturn.orphan: delivery failed (parent finished or channel full) func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, result *tools.ToolResult) { // Let GC clean up the pendingResults channel; parent Finish will no longer close it. // We use defer/recover to catch any unlikely channel panics if it were ever closed. @@ -526,7 +542,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re "recover": r, }) if result != nil && al != nil { - al.emitEvent(EventKindSubTurnOrphan, + al.emitEvent(runtimeevents.KindAgentSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "panic"}, ) @@ -541,7 +557,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re // If parent turn has already finished, treat this as an orphan result if isFinished || resultChan == nil { if result != nil && al != nil { - al.emitEvent(EventKindSubTurnOrphan, + al.emitEvent(runtimeevents.KindAgentSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"}, ) @@ -557,7 +573,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re case resultChan <- result: // Successfully delivered if al != nil { - al.emitEvent(EventKindSubTurnResultDelivered, + al.emitEvent(runtimeevents.KindAgentSubTurnResultDelivered, parentTS.eventMeta("deliverSubTurnResult", "subturn.result_delivered"), SubTurnResultDeliveredPayload{ContentLen: len(result.ForLLM)}, ) @@ -571,7 +587,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re }) if result != nil && al != nil { al.emitEvent( - EventKindSubTurnOrphan, + runtimeevents.KindAgentSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), SubTurnOrphanPayload{ ParentTurnID: parentTS.turnID, diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 040063249..e9f557c82 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -4,12 +4,16 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" + "strings" "sync" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -22,30 +26,38 @@ const ( // ====================== Test Helper: Event Collector ====================== type eventCollector struct { mu sync.Mutex - events []Event + events []runtimeevents.Event } func newEventCollector(t *testing.T, al *AgentLoop) (*eventCollector, func()) { t.Helper() c := &eventCollector{} - sub := al.SubscribeEvents(16) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSubTurnSpawn, + runtimeevents.KindAgentSubTurnEnd, + runtimeevents.KindAgentSubTurnResultDelivered, + runtimeevents.KindAgentSubTurnOrphan, + ) done := make(chan struct{}) go func() { defer close(done) - for evt := range sub.C { + for evt := range runtimeCh { c.mu.Lock() c.events = append(c.events, evt) c.mu.Unlock() } }() cleanup := func() { - al.UnsubscribeEvents(sub.ID) + closeRuntimeEvents() <-done } return c, cleanup } -func (c *eventCollector) hasEventOfKind(kind EventKind) bool { +func (c *eventCollector) hasEventOfKind(kind runtimeevents.Kind) bool { c.mu.Lock() defer c.mu.Unlock() for _, e := range c.events { @@ -131,7 +143,7 @@ func TestSpawnSubTurn(t *testing.T) { agent: al.registry.GetDefaultAgent(), } - // Subscribe to real EventBus to capture events + // Subscribe to runtime events to capture sub-turn lifecycle. collector, collectCleanup := newEventCollector(t, al) defer collectCleanup() @@ -158,12 +170,12 @@ func TestSpawnSubTurn(t *testing.T) { // Verify event emission time.Sleep(10 * time.Millisecond) // let event goroutine flush if tt.wantSpawn { - if !collector.hasEventOfKind(EventKindSubTurnSpawn) { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnSpawn) { t.Error("SubTurnSpawnEvent not emitted") } } if tt.wantEnd { - if !collector.hasEventOfKind(EventKindSubTurnEnd) { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnEnd) { t.Error("SubTurnEndEvent not emitted") } } @@ -316,8 +328,8 @@ func TestSpawnSubTurn_OrphanResultRouting(t *testing.T) { time.Sleep(10 * time.Millisecond) // let event goroutine flush // Verify Orphan event is emitted - if !collector.hasEventOfKind(EventKindSubTurnOrphan) { - t.Error("SubTurnOrphanResultEvent not emitted for finished parent") + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnOrphan) { + t.Error("agent.subturn.orphan not emitted for finished parent") } // Verify history is NOT polluted @@ -591,12 +603,16 @@ func TestNestedSubTurnHierarchy(t *testing.T) { var spawnedTurns []turnInfo var mu sync.Mutex - // Subscribe to real EventBus to capture spawn events - sub := al.SubscribeEvents(16) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 16, + runtimeevents.KindAgentSubTurnSpawn, + ) + defer closeRuntimeEvents() go func() { - for evt := range sub.C { - if evt.Kind == EventKindSubTurnSpawn { + for evt := range runtimeCh { + if evt.Kind == runtimeevents.KindAgentSubTurnSpawn { p, _ := evt.Payload.(SubTurnSpawnPayload) mu.Lock() spawnedTurns = append(spawnedTurns, turnInfo{ @@ -879,7 +895,7 @@ func TestSpawnSubTurn_PanicRecovery(t *testing.T) { time.Sleep(10 * time.Millisecond) // let event goroutine flush // SubTurnEndEvent should still be emitted - if !collector.hasEventOfKind(EventKindSubTurnEnd) { + if !collector.hasEventOfKind(runtimeevents.KindAgentSubTurnEnd) { t.Error("SubTurnEndEvent not emitted after panic") } @@ -1229,18 +1245,23 @@ func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) { al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled defer cleanup() - // Collect events via real EventBus var mu sync.Mutex var deliveredCount, orphanCount int - sub := al.SubscribeEvents(64) - defer al.UnsubscribeEvents(sub.ID) + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 64, + runtimeevents.KindAgentSubTurnResultDelivered, + runtimeevents.KindAgentSubTurnOrphan, + ) + defer closeRuntimeEvents() go func() { - for evt := range sub.C { + for evt := range runtimeCh { mu.Lock() switch evt.Kind { - case EventKindSubTurnResultDelivered: + case runtimeevents.KindAgentSubTurnResultDelivered: deliveredCount++ - case EventKindSubTurnOrphan: + case runtimeevents.KindAgentSubTurnOrphan: orphanCount++ } mu.Unlock() @@ -1795,13 +1816,20 @@ func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) { provider := &slowMockProvider{delay: 5 * time.Second} // SubTurn takes 5 seconds al := NewAgentLoop(cfg, msgBus, provider) - // Capture events via real EventBus var mu sync.Mutex - var events []Event - sub := al.SubscribeEvents(32) - defer al.UnsubscribeEvents(sub.ID) + var events []runtimeevents.Event + runtimeCh, closeRuntimeEvents := subscribeRuntimeEventsForTest( + t, + al, + 32, + runtimeevents.KindAgentSubTurnSpawn, + runtimeevents.KindAgentSubTurnEnd, + runtimeevents.KindAgentSubTurnResultDelivered, + runtimeevents.KindAgentSubTurnOrphan, + ) + defer closeRuntimeEvents() go func() { - for evt := range sub.C { + for evt := range runtimeCh { mu.Lock() events = append(events, evt) mu.Unlock() @@ -2097,3 +2125,206 @@ func TestSubTurn_IndependentContext(t *testing.T) { t.Log("✓ SubTurn completed successfully (independent context)") } } + +// ====================== TargetAgentID Tests ====================== + +// modelRecordingProvider captures the model passed to Chat for test assertions. +type modelRecordingProvider struct { + mu sync.Mutex + lastModel string +} + +func (rp *modelRecordingProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + model string, + _ map[string]any, +) (*providers.LLMResponse, error) { + rp.mu.Lock() + rp.lastModel = model + rp.mu.Unlock() + return &providers.LLMResponse{Content: "Mock response"}, nil +} + +func (rp *modelRecordingProvider) GetDefaultModel() string { return "mock-model" } + +func (rp *modelRecordingProvider) getLastModel() string { + rp.mu.Lock() + defer rp.mu.Unlock() + return rp.lastModel +} + +// newMultiAgentLoop creates an AgentLoop with two named agents for testing +// cross-agent delegation via TargetAgentID. +func newMultiAgentLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "multiagent-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + + alphaDir := filepath.Join(tmpDir, "alpha") + betaDir := filepath.Join(tmpDir, "beta") + os.MkdirAll(alphaDir, 0o755) + os.MkdirAll(betaDir, 0o755) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "default-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + { + ID: "alpha", + Workspace: alphaDir, + Model: &config.AgentModelConfig{Primary: "model-alpha"}, + }, + { + ID: "beta", + Workspace: betaDir, + Model: &config.AgentModelConfig{Primary: "model-beta"}, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + + return al, func() { os.RemoveAll(tmpDir) } +} + +func TestSpawnSubTurn_TargetAgentID_UsesTargetAgent(t *testing.T) { + rp := &modelRecordingProvider{} + al, cleanup := newMultiAgentLoop(t, rp) + defer cleanup() + + alphaAgent, ok := al.registry.GetAgent("alpha") + if !ok { + t.Fatal("alpha agent not in registry") + } + + // Parent is alpha, target is beta + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("spawnSubTurn failed: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // The recording provider captures the model passed to Chat(). + // If TargetAgentID works correctly, the child turn should have + // used beta's model, not alpha's. + if got := rp.getLastModel(); got != "model-beta" { + t.Errorf("child turn used model %q, want %q", got, "model-beta") + } +} + +func TestSpawnSubTurn_TargetAgentID_NotFound(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + _, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "nonexistent", + SystemPrompt: "task", + }) + + if err == nil { + t.Fatal("expected error for nonexistent agent") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention 'not found', got: %v", err) + } +} + +func TestSpawnSubTurn_TargetAgentID_EmptyModelAccepted(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + // Model is empty but TargetAgentID is set — should NOT fail validation + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + Model: "", // intentionally empty + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("should accept empty Model when TargetAgentID is set, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestDelegateToolNotRegistered_SingleAgent(t *testing.T) { + // Single-agent setup: delegate should not be registered + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("default agent should exist") + } + if _, has := agent.Tools.Get("delegate"); has { + t.Error("delegate tool should not be registered in single-agent setup") + } +} + +func TestDelegateToolRegistered_MultiAgent(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + // Both agents should have the delegate tool + for _, id := range []string{"alpha", "beta"} { + agent, ok := al.registry.GetAgent(id) + if !ok { + t.Fatalf("agent %q not found", id) + } + if _, has := agent.Tools.Get("delegate"); !has { + t.Errorf("agent %q should have delegate tool in multi-agent setup", id) + } + } +} diff --git a/pkg/agent/turn_context.go b/pkg/agent/turn_context.go index 8913993aa..c675590ce 100644 --- a/pkg/agent/turn_context.go +++ b/pkg/agent/turn_context.go @@ -61,7 +61,7 @@ func cloneStringMap(src map[string]string) map[string]string { return cloned } -func cloneEventMeta(meta EventMeta) EventMeta { +func cloneHookMeta(meta HookMeta) HookMeta { meta.turnContext = cloneTurnContext(meta.turnContext) return meta } diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index ade2b7c21..2826e662c 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -25,10 +26,14 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel al.registerActiveTurn(ts) defer al.clearActiveTurn(ts) + if al.takePendingStop(ts.sessionKey) { + _ = ts.requestHardAbort() + } + turnStatus := TurnEndStatusCompleted defer func() { al.emitEvent( - EventKindTurnEnd, + runtimeevents.KindAgentTurnEnd, ts.eventMeta("runTurn", "turn.end"), TurnEndPayload{ Status: turnStatus, @@ -39,8 +44,13 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel ) }() + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + al.emitEvent( - EventKindTurnStart, + runtimeevents.KindAgentTurnStart, ts.eventMeta("runTurn", "turn.start"), TurnStartPayload{ UserMessage: ts.userMessage, @@ -140,7 +150,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel }) } al.emitEvent( - EventKindSteeringInjected, + runtimeevents.KindAgentSteeringInjected, ts.eventMeta("runTurn", "turn.steering.injected"), SteeringInjectedPayload{ Count: len(pendingMessages), @@ -249,7 +259,7 @@ func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { if !ts.opts.NoHistory { if err := ts.restoreSession(ts.agent); err != nil { al.emitEvent( - EventKindError, + runtimeevents.KindAgentError, ts.eventMeta("abortTurn", "turn.error"), ErrorPayload{ Stage: "session_restore", @@ -414,7 +424,7 @@ func (al *AgentLoop) askSideQuestion( llmModel := activeModel if al.hooks != nil { llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ - Meta: EventMeta{ + Meta: HookMeta{ Source: "askSideQuestion", TracePath: "turn.llm.request", turnContext: cloneTurnContext(turnCtx), @@ -494,8 +504,8 @@ func (al *AgentLoop) askSideQuestion( resp, err = callSideLLM(messages) if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { al.emitEvent( - EventKindLLMRetry, - EventMeta{ + runtimeevents.KindAgentLLMRetry, + HookMeta{ Source: "askSideQuestion", TracePath: "turn.llm.retry", turnContext: cloneTurnContext(turnCtx), @@ -521,7 +531,7 @@ func (al *AgentLoop) askSideQuestion( // Apply after_llm hooks if al.hooks != nil { llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ - Meta: EventMeta{ + Meta: HookMeta{ Source: "askSideQuestion", TracePath: "turn.llm.response", turnContext: cloneTurnContext(turnCtx), diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go index c059d0a39..898ae3931 100644 --- a/pkg/agent/turn_coord_test.go +++ b/pkg/agent/turn_coord_test.go @@ -135,6 +135,16 @@ func (p *errorProvider) Chat( return nil, errors.New("context_length_exceeded") case "vision": return nil, errors.New("vision_unsupported") + case "connection_reset": + return nil, errors.New("connection reset by peer") + case "broken_pipe": + return nil, errors.New("broken pipe") + case "read_tcp": + return nil, errors.New("read tcp 127.0.0.1:8080: connection reset") + case "eof": + return nil, errors.New("EOF") + case "connection_refused": + return nil, errors.New("connection refused") default: return nil, errors.New("unknown error") } @@ -366,6 +376,163 @@ func TestPipeline_CallLLM_ContextLengthError(t *testing.T) { t.Logf("CallLLM result after context error: err=%v", err) } +func TestPipeline_CallLLM_NetworkErrorRetry(t *testing.T) { + testCases := []struct { + name string + errType string + }{ + {"connection_reset", "connection_reset"}, + {"broken_pipe", "broken_pipe"}, + {"read_tcp", "read_tcp"}, + {"eof", "eof"}, + {"connection_refused", "connection_refused"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + errorPrv := &errorProvider{errType: tc.errType} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after network error retries") + } + }) + } +} + +func TestPipeline_CallLLM_RetryConfigRespected(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxLLMRetries: 3, + LLMRetryBackoffSecs: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &errorProvider{errType: "connection_reset"} + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + start := time.Now() + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + elapsed := time.Since(start) + + if err == nil { + t.Error("expected error after retries") + } + + expectedMinTime := 3 * time.Second + if elapsed < expectedMinTime { + t.Errorf("expected at least %v of backoff, got %v", expectedMinTime, elapsed) + } +} + +func TestPipeline_CallLLM_RetryCountLimit(t *testing.T) { + tmpDir := t.TempDir() + + counterPrv := &countingErrorProvider{errType: "connection_reset", targetCalls: 5} + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxLLMRetries: 2, + LLMRetryBackoffSecs: 0, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, counterPrv) + defer al.Close() + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after retries") + } + + if counterPrv.callCount != 3 { + t.Errorf("expected exactly 3 calls (1 initial + 2 retries), got %d", counterPrv.callCount) + } +} + +type countingErrorProvider struct { + errType string + targetCalls int + callCount int + mu sync.Mutex +} + +func (p *countingErrorProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + p.mu.Unlock() + return nil, errors.New("connection reset by peer") +} + +func (p *countingErrorProvider) GetDefaultModel() string { + return "counting-error-model" +} + // ============================================================================= // Pipeline Method Tests: ExecuteTools // ============================================================================= diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 360c3b7d5..b769ebcd0 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -256,7 +256,10 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop // Bind session store and capture initial history length for rollback logic if agent != nil && agent.Sessions != nil { ts.session = agent.Sessions - ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.Dispatch.SessionKey)) + history := agent.Sessions.GetHistory(opts.Dispatch.SessionKey) + ts.initialHistoryLength = len(history) + ts.restorePointHistory = append([]providers.Message(nil), history...) + ts.restorePointSummary = agent.Sessions.GetSummary(opts.Dispatch.SessionKey) } return ts @@ -442,9 +445,9 @@ func (ts *turnState) hardAbortRequested() bool { return ts.hardAbort } -func (ts *turnState) eventMeta(source, tracePath string) EventMeta { +func (ts *turnState) eventMeta(source, tracePath string) HookMeta { snap := ts.snapshot() - return EventMeta{ + return HookMeta{ AgentID: snap.AgentID, TurnID: snap.TurnID, SessionKey: snap.SessionKey, diff --git a/pkg/audio/asr/README.md b/pkg/audio/asr/README.md index 0477276dd..99d2a8c90 100644 --- a/pkg/audio/asr/README.md +++ b/pkg/audio/asr/README.md @@ -82,7 +82,8 @@ Notes: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw currently supports three main ASR routes: | Route | Example models | Behavior | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. | +| ElevenLabs ASR | `provider: elevenlabs`, `model: scribe_v1` | Uses the ElevenLabs transcription API. | | Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. | | Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. | @@ -142,7 +143,7 @@ If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first. 1. **Preferred path**: resolve `voice.model_name` against `model_list`. 2. If that resolved model is: - - `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber. + - an `elevenlabs` provider model, PicoClaw uses the ElevenLabs transcriber. - an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber. - an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`. 3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries. diff --git a/pkg/audio/asr/README.zh.md b/pkg/audio/asr/README.zh.md index 104116080..670698cb8 100644 --- a/pkg/audio/asr/README.zh.md +++ b/pkg/audio/asr/README.zh.md @@ -82,7 +82,8 @@ model_list: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw 目前主要支持三种 ASR 路径: | 路径 | 示例模型 | 行为说明 | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | +| ElevenLabs ASR | `provider: elevenlabs`,`model: scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | | Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 | | 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 | @@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径: 1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。 2. 如果找到的模型属于以下类型: - - `elevenlabs/...`,则使用 ElevenLabs transcriber。 + - `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。 - OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。 - 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。 3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。 diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go index 1482f40bb..a7c93e578 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -8,6 +8,12 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +const elevenLabsSupportedModelID = "scribe_v1" + +func ElevenLabsSupportedModelID() string { + return elevenLabsSupportedModelID +} + type Transcriber interface { Name() string Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) @@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string { return "" } +func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool { + if modelCfg == nil || modelCfg.APIKey() == "" { + return false + } + + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "elevenlabs" +} + func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { if modelCfg == nil { return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) @@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) diff --git a/pkg/audio/asr/asr_test.go b/pkg/audio/asr/asr_test.go index 0970d69f4..f877b1198 100644 --- a/pkg/audio/asr/asr_test.go +++ b/pkg/audio/asr/asr_test.go @@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) { }, wantName: "elevenlabs", }, + { + name: "explicit elevenlabs provider selects elevenlabs transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + }, + }, + wantName: "elevenlabs", + }, { name: "voice model name alias selects whisper transcriber for groq", cfg: &config.Config{ diff --git a/pkg/audio/asr/elevenlabs_transcriber.go b/pkg/audio/asr/elevenlabs_transcriber.go index 452b9512d..a89d62848 100644 --- a/pkg/audio/asr/elevenlabs_transcriber.go +++ b/pkg/audio/asr/elevenlabs_transcriber.go @@ -20,19 +20,24 @@ import ( type ElevenLabsTranscriber struct { apiKey string apiBase string + modelID string httpClient *http.Client } -func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber { +func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber { logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) if apiBase == "" { apiBase = "https://api.elevenlabs.io" } + if modelID == "" || modelID != ElevenLabsSupportedModelID() { + modelID = ElevenLabsSupportedModelID() + } return &ElevenLabsTranscriber{ apiKey: apiKey, apiBase: apiBase, + modelID: modelID, httpClient: &http.Client{ Timeout: 120 * time.Second, }, @@ -74,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st return nil, fmt.Errorf("failed to copy file content: %w", err) } - if err = writer.WriteField("model_id", "scribe_v1"); err != nil { + if err = writer.WriteField("model_id", t.modelID); err != nil { return nil, fmt.Errorf("failed to write model_id field: %w", err) } diff --git a/pkg/audio/asr/elevenlabs_transcriber_test.go b/pkg/audio/asr/elevenlabs_transcriber_test.go index fa80110be..bbc827578 100644 --- a/pkg/audio/asr/elevenlabs_transcriber_test.go +++ b/pkg/audio/asr/elevenlabs_transcriber_test.go @@ -3,10 +3,14 @@ package asr import ( "context" "encoding/json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -14,7 +18,7 @@ import ( var _ Transcriber = (*ElevenLabsTranscriber)(nil) func TestElevenLabsTranscriberName(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") if got := tr.Name(); got != "elevenlabs" { t.Errorf("Name() = %q, want %q", got, "elevenlabs") } @@ -35,6 +39,35 @@ func TestElevenLabsTranscribe(t *testing.T) { if r.Header.Get("Xi-Api-Key") != "sk_test" { t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key")) } + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1") + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(TranscriptionResponse{ Text: "hello from elevenlabs", @@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") tr.apiBase = srv.URL resp, err := tr.Transcribe(context.Background(), audioPath) @@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_bad", "") + tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1") tr.apiBase = srv.URL _, err := tr.Transcribe(context.Background(), audioPath) @@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) { }) t.Run("missing file", func(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) if err == nil { t.Fatal("expected error for missing file, got nil") } }) + + t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model") + tr.apiBase = srv.URL + + if _, err := tr.Transcribe(context.Background(), audioPath); err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + }) } diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 9a05d4f95..dee67d87c 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -6,6 +6,7 @@ import ( "sync" "sync/atomic" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -48,6 +49,13 @@ type MessageBus struct { closed atomic.Bool wg sync.WaitGroup streamDelegate atomic.Value // stores StreamDelegate + eventPublisher atomic.Value // stores EventPublisher +} + +// EventPublisher is the minimal runtime event publisher used by MessageBus. +type EventPublisher interface { + Publish(ctx context.Context, evt runtimeevents.Event) runtimeevents.PublishResult + PublishNonBlocking(evt runtimeevents.Event) runtimeevents.PublishResult } func NewMessageBus() *MessageBus { @@ -92,9 +100,14 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { msg = NormalizeInboundMessage(msg) if msg.Context.isZero() { + mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingInboundContext) return ErrMissingInboundContext } - return publish(ctx, mb, mb.inbound, msg) + if err := publish(ctx, mb, mb.inbound, msg); err != nil { + mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), err) + return err + } + return nil } func (mb *MessageBus) InboundChan() <-chan InboundMessage { @@ -104,9 +117,14 @@ func (mb *MessageBus) InboundChan() <-chan InboundMessage { func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { msg = NormalizeOutboundMessage(msg) if msg.Context.isZero() { + mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundContext) return ErrMissingOutboundContext } - return publish(ctx, mb, mb.outbound, msg) + if err := publish(ctx, mb, mb.outbound, msg); err != nil { + mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), err) + return err + } + return nil } func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { @@ -116,9 +134,14 @@ func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { msg = NormalizeOutboundMediaMessage(msg) if msg.Context.isZero() { + mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundMediaContext) return ErrMissingOutboundMediaContext } - return publish(ctx, mb, mb.outboundMedia, msg) + if err := publish(ctx, mb, mb.outboundMedia, msg); err != nil { + mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), err) + return err + } + return nil } func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { @@ -126,7 +149,11 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { } func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error { - return publish(ctx, mb, mb.audioChunks, chunk) + if err := publish(ctx, mb, mb.audioChunks, chunk); err != nil { + mb.publishFailure("audio_chunk", runtimeScopeFromAudioChunk(chunk), err) + return err + } + return nil } func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk { @@ -134,7 +161,11 @@ func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk { } func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error { - return publish(ctx, mb, mb.voiceControls, ctrl) + if err := publish(ctx, mb, mb.voiceControls, ctrl); err != nil { + mb.publishFailure("voice_control", runtimeScopeFromVoiceControl(ctrl), err) + return err + } + return nil } func (mb *MessageBus) VoiceControlsChan() <-chan VoiceControl { @@ -146,6 +177,11 @@ func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) { mb.streamDelegate.Store(d) } +// SetEventPublisher registers a runtime event publisher for bus errors and lifecycle events. +func (mb *MessageBus) SetEventPublisher(p EventPublisher) { + mb.eventPublisher.Store(p) +} + // GetStreamer returns a Streamer for the given channel+chatID via the delegate. func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) { if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil { @@ -156,6 +192,7 @@ func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) ( func (mb *MessageBus) Close() { mb.closeOnce.Do(func() { + mb.publishCloseEvent(runtimeevents.KindBusCloseStarted, 0) // notify all blocked publishers to exit close(mb.done) @@ -195,6 +232,8 @@ func (mb *MessageBus) Close() { logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ "count": drained, }) + mb.publishCloseEvent(runtimeevents.KindBusCloseDrained, drained) } + mb.publishCloseEvent(runtimeevents.KindBusCloseCompleted, drained) }) } diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go index 5145d4759..a0a9e1e14 100644 --- a/pkg/bus/bus_test.go +++ b/pkg/bus/bus_test.go @@ -5,6 +5,8 @@ import ( "sync" "testing" "time" + + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) func TestPublishConsume(t *testing.T) { @@ -171,6 +173,86 @@ func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) { } } +func TestMessageBusPublishesRuntimeFailureAndCloseEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindBusPublishFailed, + runtimeevents.KindBusCloseStarted, + runtimeevents.KindBusCloseDrained, + runtimeevents.KindBusCloseCompleted, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "bus-events", Buffer: 4}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + mb := NewMessageBus() + mb.SetEventPublisher(eventBus) + + if err := mb.PublishInbound(context.Background(), InboundMessage{}); err == nil { + t.Fatal("expected PublishInbound to fail") + } + failed := receiveBusRuntimeEvent(t, eventsCh) + if failed.Kind != runtimeevents.KindBusPublishFailed || + failed.Source.Name != "inbound" || + failed.Severity != runtimeevents.SeverityError { + t.Fatalf("publish failed event = %+v", failed) + } + if failed.Attrs["stream"] != "inbound" || failed.Attrs["error"] == "" { + t.Fatalf("publish failed attrs = %#v, want stream and error", failed.Attrs) + } + + if err := mb.PublishOutbound(context.Background(), OutboundMessage{ + Context: NewOutboundContext("telegram", "chat-1", ""), + Content: "queued", + }); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + mb.Close() + + seen := map[runtimeevents.Kind]bool{} + var drainedAttrs map[string]any + for range 3 { + evt := receiveBusRuntimeEvent(t, eventsCh) + seen[evt.Kind] = true + if evt.Kind == runtimeevents.KindBusCloseDrained { + drainedAttrs = evt.Attrs + } + } + for _, kind := range []runtimeevents.Kind{ + runtimeevents.KindBusCloseStarted, + runtimeevents.KindBusCloseDrained, + runtimeevents.KindBusCloseCompleted, + } { + if !seen[kind] { + t.Fatalf("missing %s event, seen=%v", kind, seen) + } + } + if drainedAttrs["drained"] != 1 { + t.Fatalf("bus close drained attrs = %#v, want drained count", drainedAttrs) + } +} + +func receiveBusRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } +} + func TestPublishOutboundSubscribe(t *testing.T) { mb := NewMessageBus() defer mb.Close() diff --git a/pkg/bus/events.go b/pkg/bus/events.go new file mode 100644 index 000000000..4640ed1fc --- /dev/null +++ b/pkg/bus/events.go @@ -0,0 +1,88 @@ +package bus + +import ( + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +type busPublishFailedPayload struct { + Stream string `json:"stream"` + Error string `json:"error"` +} + +type busClosePayload struct { + Drained int `json:"drained,omitempty"` +} + +func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, err error) { + if mb == nil || err == nil { + return + } + publisher, ok := mb.eventPublisher.Load().(EventPublisher) + if !ok || publisher == nil { + return + } + + publisher.PublishNonBlocking(runtimeevents.Event{ + Kind: runtimeevents.KindBusPublishFailed, + Source: runtimeevents.Source{Component: "bus", Name: stream}, + Scope: scope, + Severity: runtimeevents.SeverityError, + Payload: busPublishFailedPayload{ + Stream: stream, + Error: err.Error(), + }, + Attrs: map[string]any{ + "stream": stream, + "error": err.Error(), + }, + }) +} + +func (mb *MessageBus) publishCloseEvent(kind runtimeevents.Kind, drained int) { + if mb == nil { + return + } + publisher, ok := mb.eventPublisher.Load().(EventPublisher) + if !ok || publisher == nil { + return + } + + attrs := map[string]any{} + if drained > 0 { + attrs["drained"] = drained + } + publisher.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "bus"}, + Severity: runtimeevents.SeverityInfo, + Payload: busClosePayload{Drained: drained}, + Attrs: attrs, + }) +} + +func runtimeScopeFromInboundContext(ctx InboundContext) runtimeevents.Scope { + return runtimeevents.Scope{ + Channel: ctx.Channel, + Account: ctx.Account, + ChatID: ctx.ChatID, + TopicID: ctx.TopicID, + SpaceID: ctx.SpaceID, + SpaceType: ctx.SpaceType, + ChatType: ctx.ChatType, + SenderID: ctx.SenderID, + MessageID: ctx.MessageID, + } +} + +func runtimeScopeFromAudioChunk(chunk AudioChunk) runtimeevents.Scope { + return runtimeevents.Scope{ + Channel: chunk.Channel, + ChatID: chunk.ChatID, + } +} + +func runtimeScopeFromVoiceControl(ctrl VoiceControl) runtimeevents.Scope { + return runtimeevents.Scope{ + ChatID: ctrl.ChatID, + } +} diff --git a/pkg/channels/events.go b/pkg/channels/events.go new file mode 100644 index 000000000..60e5640f0 --- /dev/null +++ b/pkg/channels/events.go @@ -0,0 +1,197 @@ +package channels + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func channelTypeForEvent(m *Manager, channelName string) string { + if m == nil || m.config == nil { + return channelName + } + if bc := m.config.Channels.Get(channelName); bc != nil && bc.Type != "" { + return bc.Type + } + return channelName +} + +func (m *Manager) publishChannelEvent( + kind runtimeevents.Kind, + channelName string, + scope runtimeevents.Scope, + severity runtimeevents.Severity, + payload any, +) { + if m == nil || m.runtimeEvents == nil { + return + } + if scope.Channel == "" { + scope.Channel = channelName + } + m.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "channel", Name: channelName}, + Scope: scope, + Severity: severity, + Payload: payload, + Attrs: channelEventAttrs(payload), + }) +} + +func channelEventAttrs(payload any) map[string]any { + switch payload := payload.(type) { + case ChannelLifecyclePayload: + attrs := map[string]any{} + setAttrString(attrs, "type", payload.Type) + setAttrString(attrs, "error", payload.Error) + return attrs + case ChannelOutboundPayload: + attrs := map[string]any{} + if payload.Media { + attrs["media"] = payload.Media + } + if payload.ContentLen > 0 { + attrs["content_len"] = payload.ContentLen + } + if len(payload.MessageIDs) > 0 { + attrs["message_ids_count"] = len(payload.MessageIDs) + } + setAttrString(attrs, "reply_to_message_id", payload.ReplyToMessageID) + setAttrString(attrs, "error", payload.Error) + if payload.Retries > 0 { + attrs["retries"] = payload.Retries + } + return attrs + default: + return nil + } +} + +func setAttrString(attrs map[string]any, key, value string) { + if value != "" { + attrs[key] = value + } +} + +func (m *Manager) publishOutboundSent( + channelName string, + msg bus.OutboundMessage, + messageIDs []string, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundSent, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{ + ContentLen: len([]rune(msg.Content)), + MessageIDs: append([]string(nil), messageIDs...), + ReplyToMessageID: msg.ReplyToMessageID, + }, + ) +} + +func (m *Manager) publishOutboundQueued( + channelName string, + msg bus.OutboundMessage, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundQueued, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{ + ContentLen: len([]rune(msg.Content)), + ReplyToMessageID: msg.ReplyToMessageID, + }, + ) +} + +func (m *Manager) publishOutboundFailed( + channelName string, + msg bus.OutboundMessage, + err error, + media bool, +) { + payload := ChannelOutboundPayload{ + Media: media, + ContentLen: len([]rune(msg.Content)), + ReplyToMessageID: msg.ReplyToMessageID, + Retries: maxRetries, + } + if err != nil { + payload.Error = err.Error() + } + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundFailed, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityError, + payload, + ) +} + +func (m *Manager) publishOutboundMediaSent( + channelName string, + msg bus.OutboundMediaMessage, + messageIDs []string, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundSent, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{ + Media: true, + MessageIDs: append([]string(nil), messageIDs...), + }, + ) +} + +func (m *Manager) publishOutboundMediaQueued( + channelName string, + msg bus.OutboundMediaMessage, +) { + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundQueued, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityInfo, + ChannelOutboundPayload{Media: true}, + ) +} + +func (m *Manager) publishOutboundMediaFailed( + channelName string, + msg bus.OutboundMediaMessage, + err error, +) { + payload := ChannelOutboundPayload{ + Media: true, + Retries: maxRetries, + } + if err != nil { + payload.Error = err.Error() + } + m.publishChannelEvent( + runtimeevents.KindChannelMessageOutboundFailed, + channelName, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityError, + payload, + ) +} + +func scopeFromOutboundContext(ctx bus.InboundContext) runtimeevents.Scope { + return runtimeevents.Scope{ + Channel: ctx.Channel, + Account: ctx.Account, + ChatID: ctx.ChatID, + TopicID: ctx.TopicID, + SpaceID: ctx.SpaceID, + SpaceType: ctx.SpaceType, + ChatType: ctx.ChatType, + SenderID: ctx.SenderID, + MessageID: ctx.MessageID, + } +} diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index 81238460a..95579df09 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -64,6 +64,62 @@ func extractJSONStringField(content, field string) string { // Format: {"image_key": "img_xxx"} func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") } +// extractPostImageKeys extracts all image_key values from a Feishu post (rich text) +// message. Post messages have nested arrays of elements where images appear as +// {"tag":"img","image_key":"img_xxx"}. +func extractPostImageKeys(rawContent string) []string { + if rawContent == "" { + return nil + } + + var post map[string]json.RawMessage + if err := json.Unmarshal([]byte(rawContent), &post); err != nil { + return nil + } + + var keys []string + seen := make(map[string]struct{}) + + collectFromRows := func(contentRaw json.RawMessage) { + var rows [][]map[string]any + if err := json.Unmarshal(contentRaw, &rows); err != nil { + return + } + for _, row := range rows { + for _, elem := range row { + if tag, _ := elem["tag"].(string); tag == "img" { + if ik, _ := elem["image_key"].(string); ik != "" { + if _, dup := seen[ik]; !dup { + seen[ik] = struct{}{} + keys = append(keys, ik) + } + } + } + } + } + } + + // Flat format: {"title":"...", "content":[[...]]} + if contentRaw, ok := post["content"]; ok { + collectFromRows(contentRaw) + } + + // Localized format: {"zh_cn": {"title":"...", "content":[[...]]}, ...} + for _, raw := range post { + var locale map[string]json.RawMessage + if err := json.Unmarshal(raw, &locale); err != nil { + continue + } + contentRaw, ok := locale["content"] + if !ok { + continue + } + collectFromRows(contentRaw) + } + + return keys +} + // extractFileKey extracts the file_key from a Feishu file/audio message content JSON. // Format: {"file_key": "file_xxx", "file_name": "...", ...} func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index ff4af0148..dcf7861a2 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -291,6 +291,100 @@ func TestStripMentionPlaceholders(t *testing.T) { } } +func TestExtractPostImageKeys(t *testing.T) { + tests := []struct { + name string + content string + want []string + }{ + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "invalid JSON", + content: "not json", + want: nil, + }, + { + name: "post with no images", + content: `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"hello"}]]}}`, + want: nil, + }, + { + name: "post with one image", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_v3_001"}]]}}`, + want: []string{"img_v3_001"}, + }, + { + name: "post with multiple images", + content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"see"},{"tag":"img","image_key":"img_001"}],[{"tag":"img","image_key":"img_002"}]]}}`, + want: []string{"img_001", "img_002"}, + }, + { + name: "post with text and image mixed in row", + content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"hi"},{"tag":"img","image_key":"img_mix"}]]}}`, + want: []string{"img_mix"}, + }, + { + name: "en_us locale", + content: `{"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`, + want: []string{"img_en"}, + }, + { + name: "multiple locales with distinct images", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_zh"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`, + want: []string{"img_zh", "img_en"}, + }, + { + name: "duplicate image_key across locales is deduplicated", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]}}`, + want: []string{"img_same"}, + }, + { + name: "image with empty image_key", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":""}]]}}`, + want: nil, + }, + { + name: "flat format without locale wrapper", + content: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_flat","width":1826,"height":338}],[{"tag":"text","text":" check this image","style":[]}]]}`, + want: []string{"img_v3_flat"}, + }, + { + name: "flat format multiple images", + content: `{"title":"","content":[[{"tag":"img","image_key":"img_flat_1"}],[{"tag":"img","image_key":"img_flat_2"},{"tag":"text","text":"desc"}]]}`, + want: []string{"img_flat_1", "img_flat_2"}, + }, + { + name: "flat format no images", + content: `{"title":"Test","content":[[{"tag":"text","text":"just text"}]]}`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPostImageKeys(tt.content) + if len(got) != len(tt.want) { + t.Errorf("extractPostImageKeys() = %v, want %v", got, tt.want) + return + } + // Use set comparison to avoid map iteration order dependency + gotSet := make(map[string]bool, len(got)) + for _, v := range got { + gotSet[v] = true + } + for _, v := range tt.want { + if !gotSet[v] { + t.Errorf("extractPostImageKeys() missing expected key %q; got %v", v, got) + } + } + }) + } +} + func TestExtractCardImageKeys(t *testing.T) { tests := []struct { name string diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 8f3ae39d9..d09c021c7 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -803,6 +803,14 @@ func (c *FeishuChannel) downloadInboundMedia( refs = append(refs, ref) } + case larkim.MsgTypePost: + for _, imageKey := range extractPostImageKeys(rawContent) { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + case larkim.MsgTypeInteractive: // Extract and download images embedded in interactive cards feishuKeys, _ := extractCardImageKeys(rawContent) @@ -842,12 +850,41 @@ func (c *FeishuChannel) downloadInboundMedia( // downloadResource downloads a message resource (image/file) from Feishu, // writes it to the project media directory, and stores the reference in MediaStore. // fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension. +// +// For image resources, if the primary MessageResource.Get API fails (which +// requires im:message or im:message:readonly scope), a fallback to the +// Image.Get API (which requires im:resource scope) is attempted. This ensures +// image downloads succeed regardless of which permission the user has granted. func (c *FeishuChannel) downloadResource( ctx context.Context, messageID, fileKey, resourceType, fallbackExt string, store media.MediaStore, scope string, ) string { + file, filename := c.fetchResourceData(ctx, messageID, fileKey, resourceType) + if file == nil { + return "" + } + if closer, ok := file.(io.Closer); ok { + defer closer.Close() + } + + if filename == "" { + filename = fileKey + } + if filepath.Ext(filename) == "" && fallbackExt != "" { + filename += fallbackExt + } + + return c.storeResourceFile(ctx, messageID, fileKey, filename, file, store, scope) +} + +// fetchResourceData tries to download a resource from Feishu, first via +// MessageResource.Get, then falling back to Image.Get for image resources. +func (c *FeishuChannel) fetchResourceData( + ctx context.Context, + messageID, fileKey, resourceType string, +) (io.Reader, string) { req := larkim.NewGetMessageResourceReqBuilder(). MessageId(messageID). FileKey(fileKey). @@ -855,41 +892,80 @@ func (c *FeishuChannel) downloadResource( Build() resp, err := c.client.Im.V1.MessageResource.Get(ctx, req) + if err == nil && resp.Success() && resp.File != nil { + return resp.File, resp.FileName + } + if err != nil { - logger.ErrorCF("feishu", "Failed to download resource", map[string]any{ + logger.WarnCF("feishu", "MessageResource.Get failed", map[string]any{ "message_id": messageID, "file_key": fileKey, "error": err.Error(), }) - return "" + } else if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + logger.WarnCF("feishu", "MessageResource.Get api error", map[string]any{ + "message_id": messageID, + "file_key": fileKey, + "code": resp.Code, + "msg": resp.Msg, + }) + } else { + logger.WarnCF("feishu", "MessageResource.Get returned empty file body", map[string]any{ + "message_id": messageID, + "file_key": fileKey, + }) + } + + if resourceType != "image" { + return nil, "" + } + + return c.fetchImageDirect(ctx, fileKey) +} + +// fetchImageDirect downloads an image using the Image.Get API +// (/open-apis/im/v1/images/:image_key), which requires the im:resource scope. +func (c *FeishuChannel) fetchImageDirect(ctx context.Context, imageKey string) (io.Reader, string) { + req := larkim.NewGetImageReqBuilder(). + ImageKey(imageKey). + Build() + + resp, err := c.client.Im.V1.Image.Get(ctx, req) + if err != nil { + logger.ErrorCF("feishu", "Image.Get fallback failed", map[string]any{ + "image_key": imageKey, + "error": err.Error(), + }) + return nil, "" } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) - logger.ErrorCF("feishu", "Resource download api error", map[string]any{ - "code": resp.Code, - "msg": resp.Msg, + logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{ + "image_key": imageKey, + "code": resp.Code, + "msg": resp.Msg, }) - return "" + return nil, "" } - if resp.File == nil { - return "" - } - // Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body). - if closer, ok := resp.File.(io.Closer); ok { - defer closer.Close() + return nil, "" } - filename := resp.FileName - if filename == "" { - filename = fileKey - } - // If filename still has no extension, append the fallback (like Telegram's ext parameter). - if filepath.Ext(filename) == "" && fallbackExt != "" { - filename += fallbackExt - } + logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{ + "image_key": imageKey, + }) + return resp.File, resp.FileName +} - // Write to the shared picoclaw_media directory using a unique name to avoid collisions. +// storeResourceFile writes downloaded resource data to disk and registers it in the MediaStore. +func (c *FeishuChannel) storeResourceFile( + ctx context.Context, + messageID, fileKey, filename string, + file io.Reader, + store media.MediaStore, + scope string, +) string { mediaDir := media.TempDir() if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ @@ -908,7 +984,7 @@ func (c *FeishuChannel) downloadResource( return "" } - if _, copyErr := io.Copy(out, resp.File); copyErr != nil { + if _, copyErr := io.Copy(out, file); copyErr != nil { out.Close() os.Remove(localPath) logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ @@ -943,8 +1019,8 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { return content } - // Don't append tags to JSON content (interactive cards) - would produce invalid JSON - if messageType == larkim.MsgTypeInteractive { + // Don't append tags to JSON content - would produce invalid JSON + if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost { return content } diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 48fdf0f74..d256325ad 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -180,6 +180,13 @@ func TestAppendMediaTags(t *testing.T) { mediaRefs: []string{"ref1"}, want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, }, + { + name: "post message with images returns content unchanged", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`, + messageType: "post", + mediaRefs: []string{"ref1"}, + want: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`, + }, } for _, tt := range tests { diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 6d9b29ce3..6cc9f0cd9 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -61,7 +61,10 @@ func NewLINEChannel( return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } - client, err := messaging_api.NewMessagingApiAPI(cfg.ChannelAccessToken.String()) + client, err := messaging_api.NewMessagingApiAPI( + cfg.ChannelAccessToken.String(), + messaging_api.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), + ) if err != nil { return nil, fmt.Errorf("failed to create LINE messaging client: %w", err) } @@ -456,7 +459,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { - _, err := c.client.WithContext(ctx).ReplyMessage(&messaging_api.ReplyMessageRequest{ + _, _, err := c.client.WithContext(ctx).ReplyMessageWithHttpInfo(&messaging_api.ReplyMessageRequest{ ReplyToken: tokenEntry.token, Messages: []messaging_api.MessageInterface{&textMsg}, }) @@ -467,16 +470,18 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri }) return nil, nil } - logger.DebugC("line", "Reply API failed, falling back to Push API") + logger.DebugCF("line", "Reply API failed, falling back to Push API", map[string]any{ + "error": err.Error(), + }) } } // Fall back to Push API - _, err := c.client.WithContext(ctx).PushMessage(&messaging_api.PushMessageRequest{ + resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{ To: msg.ChatID, Messages: []messaging_api.MessageInterface{&textMsg}, }, "") - return nil, err + return nil, classifySDKError(resp, err) } // SendMedia implements the channels.MediaSender interface. @@ -502,11 +507,12 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag } textMsg := messaging_api.TextMessage{Text: caption} - if _, err := c.client.WithContext(ctx).PushMessage(&messaging_api.PushMessageRequest{ + resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{ To: msg.ChatID, Messages: []messaging_api.MessageInterface{&textMsg}, - }, ""); err != nil { - return nil, err + }, "") + if err != nil { + return nil, classifySDKError(resp, err) } } @@ -558,13 +564,24 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e return stop, nil } +// classifySDKError maps an SDK HTTP response to the project's sentinel errors. +func classifySDKError(resp *http.Response, err error) error { + if err == nil { + return nil + } + if resp != nil { + return channels.ClassifySendError(resp.StatusCode, err) + } + return channels.ClassifyNetError(err) +} + // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { - _, err := c.client.WithContext(ctx).ShowLoadingAnimation(&messaging_api.ShowLoadingAnimationRequest{ + resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(&messaging_api.ShowLoadingAnimationRequest{ ChatId: chatID, LoadingSeconds: 60, }) - return err + return classifySDKError(resp, err) } // downloadContent downloads media content from the LINE content API. diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index d56c4fd9b..9d6ca543f 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -23,6 +23,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" @@ -84,6 +85,7 @@ type Manager struct { channels map[string]Channel workers map[string]*channelWorker bus *bus.MessageBus + runtimeEvents runtimeevents.Bus config *config.Config mediaStore media.MediaStore dispatchTask *asyncTask @@ -98,6 +100,32 @@ type Manager struct { channelHashes map[string]string // channel name → config hash } +// ManagerOption configures a channel Manager. +type ManagerOption func(*Manager) + +// WithRuntimeEvents injects the runtime event bus used for channel observations. +func WithRuntimeEvents(eventBus runtimeevents.Bus) ManagerOption { + return func(m *Manager) { + m.runtimeEvents = eventBus + } +} + +// ChannelLifecyclePayload describes channel lifecycle runtime events. +type ChannelLifecyclePayload struct { + Type string `json:"type,omitempty"` + Error string `json:"error,omitempty"` +} + +// ChannelOutboundPayload describes channel outbound message runtime events. +type ChannelOutboundPayload struct { + Media bool `json:"media,omitempty"` + ContentLen int `json:"content_len,omitempty"` + MessageIDs []string `json:"message_ids,omitempty"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + Error string `json:"error,omitempty"` + Retries int `json:"retries,omitempty"` +} + type toolFeedbackMessageTracker interface { RecordToolFeedbackMessage(chatID, messageID, content string) ClearToolFeedbackMessage(chatID string) @@ -192,6 +220,21 @@ func clearTrackedToolFeedbackMessage( } } +// DismissToolFeedback clears any tracked tool feedback animation for the +// given channel/chat. This is called when a turn ends without a final +// response (e.g., ResponseHandled tools) to stop orphaned animation goroutines. +// outboundCtx carries topic/thread info for channels that use scoped tracker +// keys (e.g., Telegram forum topics); may be nil for non-topic channels. +func (m *Manager) DismissToolFeedback( + ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext, +) { + ch, ok := m.GetChannel(channelName) + if !ok { + return + } + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx) +} + func prepareToolFeedbackMessageContent(ch Channel, content string) string { prepared := strings.TrimSpace(content) if prepared == "" { @@ -409,7 +452,12 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun } } -func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { +func NewManager( + cfg *config.Config, + messageBus *bus.MessageBus, + store media.MediaStore, + opts ...ManagerOption, +) (*Manager, error) { m := &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), @@ -418,6 +466,11 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi mediaStore: store, channelHashes: make(map[string]string), } + for _, opt := range opts { + if opt != nil { + opt(m) + } + } // Register as streaming delegate so the agent loop can obtain streamers messageBus.SetStreamDelegate(m) @@ -542,6 +595,13 @@ func (m *Manager) initChannel(typeName, channelName string) { setter.SetOwner(ch) } m.channels[channelName] = ch + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleInitialized, + channelName, + runtimeevents.Scope{Channel: channelName}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: typeName}, + ) logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": channelName, "type": typeName, @@ -687,6 +747,13 @@ func (m *Manager) registerHTTPHandlersLocked() { func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) { if wh, ok := ch.(WebhookHandler); ok { m.mux.Handle(wh.WebhookPath(), wh) + m.publishChannelEvent( + runtimeevents.KindChannelWebhookRegistered, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)}, + ) logger.InfoCF("channels", "Webhook handler registered", map[string]any{ "channel": name, "path": wh.WebhookPath(), @@ -706,6 +773,13 @@ func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) { func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) { if wh, ok := ch.(WebhookHandler); ok { m.mux.Unhandle(wh.WebhookPath()) + m.publishChannelEvent( + runtimeevents.KindChannelWebhookUnregistered, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)}, + ) logger.InfoCF("channels", "Webhook handler unregistered", map[string]any{ "channel": name, "path": wh.WebhookPath(), @@ -744,6 +818,13 @@ func (m *Manager) StartAll(ctx context.Context) error { "channel": name, "error": err.Error(), }) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStartFailed, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityError, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()}, + ) failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err)) failedNames = append(failedNames, name) continue @@ -759,6 +840,13 @@ func (m *Manager) StartAll(ctx context.Context) error { m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStarted, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelType}, + ) } if len(m.channels) > 0 && len(m.workers) == 0 { @@ -895,7 +983,15 @@ func (m *Manager) StopAll(ctx context.Context) error { "channel": name, "error": err.Error(), }) + continue } + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStopped, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)}, + ) } logger.InfoC("channels", "All channels stopped") @@ -1005,11 +1101,23 @@ func (m *Manager) sendWithRetry( // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { // ctx canceled, shutting down + m.publishChannelEvent( + runtimeevents.KindChannelRateLimited, + name, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityWarn, + ChannelOutboundPayload{ + ContentLen: len([]rune(msg.Content)), + ReplyToMessageID: msg.ReplyToMessageID, + Error: err.Error(), + }, + ) return nil, false } // Pre-send: stop typing and try to edit placeholder if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled { + m.publishOutboundSent(name, msg, msgIDs) return msgIDs, true } @@ -1018,6 +1126,7 @@ func (m *Manager) sendWithRetry( for attempt := 0; attempt <= maxRetries; attempt++ { msgIDs, lastErr = w.ch.Send(ctx, msg) if lastErr == nil { + m.publishOutboundSent(name, msg, msgIDs) return msgIDs, true } @@ -1057,6 +1166,7 @@ func (m *Manager) sendWithRetry( "error": lastErr.Error(), "retries": maxRetries, }) + m.publishOutboundFailed(name, msg, lastErr, false) return nil, false } @@ -1119,6 +1229,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { select { case w.queue <- msg: + m.publishOutboundQueued(outboundMessageChannel(msg), msg) return true case <-ctx.Done(): return false @@ -1139,6 +1250,7 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) { func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { select { case w.mediaQueue <- msg: + m.publishOutboundMediaQueued(outboundMediaChannel(msg), msg) return true case <-ctx.Done(): return false @@ -1188,6 +1300,16 @@ func (m *Manager) sendMediaWithRetry( // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { + m.publishChannelEvent( + runtimeevents.KindChannelRateLimited, + name, + scopeFromOutboundContext(msg.Context), + runtimeevents.SeverityWarn, + ChannelOutboundPayload{ + Media: true, + Error: err.Error(), + }, + ) return nil, err } @@ -1199,6 +1321,7 @@ func (m *Manager) sendMediaWithRetry( for attempt := 0; attempt <= maxRetries; attempt++ { msgIDs, lastErr = ms.SendMedia(ctx, msg) if lastErr == nil { + m.publishOutboundMediaSent(name, msg, msgIDs) return msgIDs, nil } @@ -1238,6 +1361,7 @@ func (m *Manager) sendMediaWithRetry( "error": lastErr.Error(), "retries": maxRetries, }) + m.publishOutboundMediaFailed(name, msg, lastErr) return nil, lastErr } @@ -1375,6 +1499,13 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error { "channel": name, "error": err.Error(), }) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStartFailed, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityError, + ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()}, + ) continue } // Lazily create worker only after channel starts successfully @@ -1388,6 +1519,13 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error { m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) + m.publishChannelEvent( + runtimeevents.KindChannelLifecycleStarted, + name, + runtimeevents.Scope{Channel: name}, + runtimeevents.SeverityInfo, + ChannelLifecyclePayload{Type: channelType}, + ) deferFuncs = append(deferFuncs, func() { m.RegisterChannel(name, channel) }) @@ -1510,6 +1648,7 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten if wExists && w != nil { select { case w.queue <- msg: + m.publishOutboundQueued(channelName, msg) return nil case <-ctx.Done(): return ctx.Err() diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 6c518780d..5aeabc888 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -242,6 +243,57 @@ func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) { } } +func TestStartAllPublishesLifecycleRuntimeEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().SubscribeChan( + t.Context(), + runtimeevents.SubscribeOptions{Name: "channel-lifecycle", Buffer: 4}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + m := newTestManager() + m.runtimeEvents = eventBus + m.config = &config.Config{Channels: config.ChannelsConfig{}} + m.channels["good"] = &mockChannel{} + m.channels["bad"] = &mockChannel{ + startFn: func(_ context.Context) error { return errors.New("bad start") }, + } + + if err := m.StartAll(t.Context()); err != nil { + t.Fatalf("StartAll() error = %v", err) + } + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := m.StopAll(stopCtx); err != nil { + t.Errorf("StopAll() error = %v", err) + } + }) + + events := []runtimeevents.Event{ + receiveChannelRuntimeEvent(t, eventsCh), + receiveChannelRuntimeEvent(t, eventsCh), + } + seen := map[runtimeevents.Kind]runtimeevents.Event{} + for _, evt := range events { + seen[evt.Kind] = evt + } + if evt, ok := seen[runtimeevents.KindChannelLifecycleStarted]; !ok || evt.Scope.Channel != "good" { + t.Fatalf("missing started event for good channel: %+v", events) + } + if evt, ok := seen[runtimeevents.KindChannelLifecycleStartFailed]; !ok || evt.Scope.Channel != "bad" { + t.Fatalf("missing failed event for bad channel: %+v", events) + } +} + func testOutboundMessage(msg bus.OutboundMessage) bus.OutboundMessage { if msg.Context.Channel == "" && msg.Context.ChatID == "" { msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, msg.ReplyToMessageID) @@ -256,6 +308,21 @@ func testOutboundMediaMessage(msg bus.OutboundMediaMessage) bus.OutboundMediaMes return bus.NormalizeOutboundMediaMessage(msg) } +func receiveChannelRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } +} + func TestSendWithRetry_Success(t *testing.T) { m := newTestManager() var callCount int @@ -280,6 +347,69 @@ func TestSendWithRetry_Success(t *testing.T) { } } +func TestSendWithRetryPublishesOutboundRuntimeEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindChannelMessageOutboundSent, + runtimeevents.KindChannelMessageOutboundFailed, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "channel-outbound", Buffer: 2}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + m := newTestManager() + m.runtimeEvents = eventBus + + successWorker := &channelWorker{ + ch: &mockChannel{}, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.sendWithRetry( + context.Background(), + "test", + successWorker, + testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-1", Content: "hello"}), + ) + sent := receiveChannelRuntimeEvent(t, eventsCh) + if sent.Kind != runtimeevents.KindChannelMessageOutboundSent || sent.Scope.ChatID != "chat-1" { + t.Fatalf("sent event = %+v", sent) + } + if sent.Attrs["content_len"] != 5 { + t.Fatalf("sent attrs = %#v, want content_len", sent.Attrs) + } + + failWorker := &channelWorker{ + ch: &mockChannel{ + sendFn: func(context.Context, bus.OutboundMessage) error { + return fmt.Errorf("send failed: %w", ErrSendFailed) + }, + }, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.sendWithRetry( + context.Background(), + "test", + failWorker, + testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-2", Content: "hello"}), + ) + failed := receiveChannelRuntimeEvent(t, eventsCh) + if failed.Kind != runtimeevents.KindChannelMessageOutboundFailed || failed.Scope.ChatID != "chat-2" { + t.Fatalf("failed event = %+v", failed) + } + if failed.Severity != runtimeevents.SeverityError { + t.Fatalf("failed severity = %q", failed.Severity) + } + if failed.Attrs["error"] == "" || failed.Attrs["retries"] != maxRetries { + t.Fatalf("failed attrs = %#v, want error and retries", failed.Attrs) + } +} + func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { m := newTestManager() var callCount int diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 5ee028bae..2b167e457 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -333,18 +333,33 @@ func TestIsThoughtPayload(t *testing.T) { want bool }{ { - name: "explicit thought bool", + name: "explicit thought kind", + payload: map[string]any{PayloadKeyKind: MessageKindThought}, + want: true, + }, + { + name: "thought kind ignores case and whitespace", + payload: map[string]any{PayloadKeyKind: " ThOuGhT "}, + want: true, + }, + { + name: "legacy thought bool remains supported for inbound compatibility", payload: map[string]any{PayloadKeyThought: true}, want: true, }, { - name: "thought false", + name: "legacy thought false", payload: map[string]any{PayloadKeyThought: false}, want: false, }, { - name: "thought string ignored", - payload: map[string]any{PayloadKeyThought: "true"}, + name: "tool calls kind", + payload: map[string]any{PayloadKeyKind: MessageKindToolCalls}, + want: false, + }, + { + name: "non-string kind ignored", + payload: map[string]any{PayloadKeyKind: true}, want: false, }, { @@ -380,7 +395,7 @@ func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) { Type: TypeMessageCreate, Payload: map[string]any{ PayloadKeyContent: "internal reasoning", - PayloadKeyThought: true, + PayloadKeyKind: MessageKindThought, }, }) @@ -390,3 +405,31 @@ func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) { case <-time.After(150 * time.Millisecond): } } + +func TestPicoClientChannel_HandleServerMessage_IgnoresLegacyThoughtBool(t *testing.T) { + mb := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: "ws://localhost:8080/ws", + }, mb) + if err != nil { + t.Fatalf("NewPicoClientChannel() error = %v", err) + } + + ch.ctx = context.Background() + pc := &picoConn{sessionID: "sess-thought-legacy"} + + ch.handleServerMessage(pc, PicoMessage{ + Type: TypeMessageCreate, + Payload: map[string]any{ + PayloadKeyContent: "legacy internal reasoning", + PayloadKeyThought: true, + }, + }) + + select { + case msg := <-mb.InboundChan(): + t.Fatalf("expected no inbound publish for legacy thought payload, got %+v", msg) + case <-time.After(150 * time.Millisecond): + } +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 9bd8a5b5d..d1de8f4d5 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -323,10 +323,18 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri payload := map[string]any{ PayloadKeyContent: content, - PayloadKeyThought: isThought, "message_id": msgID, } - if isToolCalls { + switch { + case isThought: + payload[PayloadKeyKind] = MessageKindThought + + // This field is kept solely for compatibility with legacy pico clients that + // do not yet support the newer "kind" field. + // DO NOT use it for any purpose other than legacy client compatibility. + payload[PayloadKeyThought] = true + + case isToolCalls: payload[PayloadKeyKind] = MessageKindToolCalls if toolCalls, ok := picoToolCallsPayload(msg); ok { payload[PayloadKeyToolCalls] = toolCalls @@ -457,7 +465,6 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ PayloadKeyContent: text, - PayloadKeyThought: false, "message_id": msgID, }) diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 22ed5451a..bbe73a222 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -131,8 +131,8 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { if got := payload[PayloadKeyContent]; got != "thinking trace" { t.Fatalf("thought content = %#v, want %q", got, "thinking trace") } - if got := payload[PayloadKeyThought]; got != true { - t.Fatalf("thought flag = %#v, want true", got) + if got := payload[PayloadKeyKind]; got != MessageKindThought { + t.Fatalf("thought kind = %#v, want %q", got, MessageKindThought) } if got := payload["message_id"]; got == "msg-progress" || got == nil || got == "" { t.Fatalf("thought message_id = %#v, want new non-progress id", got) @@ -193,6 +193,47 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { } } +func TestSendPlaceholder_EmitsNormalMessageWithoutKind(t *testing.T) { + ch := newTestPicoChannel(t) + ch.bc.Placeholder.Enabled = true + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + msgID, err := ch.SendPlaceholder(context.Background(), "pico:sess-1") + if err != nil { + t.Fatalf("SendPlaceholder() error = %v", err) + } + if msgID == "" { + t.Fatal("expected placeholder message id") + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("placeholder message type = %q, want %q", msg.Type, TypeMessageCreate) + } + payload := msg.Payload + if got := payload["message_id"]; got != msgID { + t.Fatalf("placeholder message_id = %#v, want %q", got, msgID) + } + if got := payload[PayloadKeyContent]; got != "Thinking..." { + t.Fatalf("placeholder content = %#v, want %q", got, "Thinking...") + } + if got, ok := payload[PayloadKeyKind]; ok { + t.Fatalf("placeholder kind = %#v, want absent", got) + } + case <-time.After(time.Second): + t.Fatal("expected placeholder message to be delivered") + } +} + func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) { ch := newTestPicoChannel(t) diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 46e8fa3ee..6e3a5ca89 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -1,6 +1,9 @@ package pico -import "time" +import ( + "strings" + "time" +) // Protocol message types. const ( @@ -47,6 +50,13 @@ func newMessage(msgType string, payload map[string]any) PicoMessage { } func isThoughtPayload(payload map[string]any) bool { + kind, _ := payload[PayloadKeyKind].(string) + if strings.EqualFold(strings.TrimSpace(kind), MessageKindThought) { + return true + } + + // Keep pico_client inbound-compatible with legacy servers that still send + // the pre-kind boolean thought marker. thought, _ := payload[PayloadKeyThought].(bool) return thought } diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index a7e401bb8..e268812a0 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -8,6 +8,7 @@ func BuiltinDefinitions() []Definition { return []Definition{ startCommand(), helpCommand(), + stopCommand(), showCommand(), listCommand(), useCommand(), diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index efd27fa00..bb9abe360 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -42,6 +42,9 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } + if !strings.Contains(reply, "/stop") { + t.Fatalf("/help reply missing /stop usage, got %q", reply) + } if !strings.Contains(reply, "/use ") { if !strings.Contains(reply, "/use [message]") { t.Fatalf("/help reply missing /use usage, got %q", reply) @@ -49,6 +52,59 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { } } +func TestBuiltinStop_UsesRuntimeStopper(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{ + Stopped: true, + TaskName: "sync the long running job", + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Task stopped. \"sync the long running job\" was canceled." { + t.Fatalf("/stop reply=%q", reply) + } +} + +func TestBuiltinStop_NoActiveTask(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{}, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "No active task to stop." { + t.Fatalf("/stop reply=%q, want no-active message", reply) + } +} + func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) diff --git a/pkg/commands/cmd_stop.go b/pkg/commands/cmd_stop.go new file mode 100644 index 000000000..147688bdc --- /dev/null +++ b/pkg/commands/cmd_stop.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func stopCommand() Definition { + return Definition{ + Name: "stop", + Description: "Stop the current task", + Usage: "/stop", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.StopActiveTurn == nil { + return req.Reply(unavailableMsg) + } + + result, err := rt.StopActiveTurn() + if err != nil { + return req.Reply("Failed to stop task: " + err.Error()) + } + + return req.Reply(FormatStopReply(result)) + }, + } +} + +// FormatStopReply renders a user-facing reply for a stop request. +func FormatStopReply(result StopResult) string { + if !result.Stopped { + return "No active task to stop." + } + + taskName := compactStopTaskName(result.TaskName) + if taskName == "" { + return "Task stopped. Current task was canceled." + } + + return fmt.Sprintf("Task stopped. %q was canceled.", taskName) +} + +func compactStopTaskName(taskName string) string { + taskName = strings.Join(strings.Fields(strings.TrimSpace(taskName)), " ") + if taskName == "" { + return "" + } + if len(taskName) > 80 { + return taskName[:77] + "..." + } + return taskName +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index c17b7cf1c..b0327c863 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -36,6 +36,12 @@ type ContextStats struct { MessageCount int } +// StopResult describes the outcome of a stop request for the current session. +type StopResult struct { + Stopped bool + TaskName string +} + // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). @@ -55,4 +61,5 @@ type Runtime struct { SwitchChannel func(value string) error ClearHistory func() error ReloadConfig func() error + StopActiveTurn func() (StopResult, error) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 16497b4ac..acceee4d5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,6 +17,7 @@ import ( "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" + providercommon "github.com/sipeed/picoclaw/pkg/providers/common" ) // rrCounter is a global counter for round-robin load balancing across models. @@ -39,6 +40,7 @@ type Config struct { Channels ChannelsConfig `json:"channel_list" yaml:"channel_list"` ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway" yaml:"-"` + Events EventsConfig `json:"events,omitempty" yaml:"-"` Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` Tools ToolsConfig `json:"tools" yaml:",inline"` Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` @@ -276,6 +278,8 @@ type AgentDefaults struct { SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` + MaxLLMRetries int `json:"max_llm_retries,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_LLM_RETRIES"` + LLMRetryBackoffSecs int `json:"llm_retry_backoff_secs,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_LLM_RETRY_BACKOFF_SECS"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB @@ -553,12 +557,13 @@ type ModelConfig struct { Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations - RPM int `json:"rpm,omitempty"` // Requests per minute limit - MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") - RequestTimeout int `json:"request_timeout,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive - ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body - CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Additional headers to inject into every HTTP request + RPM int `json:"rpm,omitempty"` // Requests per minute limit + MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive + ToolSchemaTransform string `json:"tool_schema_transform,omitempty"` // Optional tool schema compatibility transform (e.g. "simple") + ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body + CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Additional headers to inject into every HTTP request APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) @@ -595,6 +600,9 @@ func (c *ModelConfig) Validate() error { if c.Model == "" { return fmt.Errorf("model is required") } + if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil { + return err + } return nil } @@ -823,6 +831,7 @@ type ToolsConfig struct { ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"` SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"` Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` @@ -1465,23 +1474,24 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { // Create a copy for the additional key additionalEntry := &ModelConfig{ - ModelName: expandedName, - Provider: m.Provider, - Model: m.Model, - APIBase: m.APIBase, - APIKeys: SimpleSecureStrings(keys[i]), - Proxy: m.Proxy, - AuthMethod: m.AuthMethod, - ConnectMode: m.ConnectMode, - Workspace: m.Workspace, - RPM: m.RPM, - MaxTokensField: m.MaxTokensField, - RequestTimeout: m.RequestTimeout, - ThinkingLevel: m.ThinkingLevel, - ExtraBody: m.ExtraBody, - CustomHeaders: m.CustomHeaders, - UserAgent: m.UserAgent, - isVirtual: true, + ModelName: expandedName, + Provider: m.Provider, + Model: m.Model, + APIBase: m.APIBase, + APIKeys: SimpleSecureStrings(keys[i]), + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + ToolSchemaTransform: m.ToolSchemaTransform, + ExtraBody: m.ExtraBody, + CustomHeaders: m.CustomHeaders, + UserAgent: m.UserAgent, + isVirtual: true, } expanded = append(expanded, additionalEntry) fallbackNames = append(fallbackNames, expandedName) @@ -1489,22 +1499,23 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { // Create the primary entry with first key and fallbacks primaryEntry := &ModelConfig{ - ModelName: originalName, - Provider: m.Provider, - Model: m.Model, - APIBase: m.APIBase, - Proxy: m.Proxy, - AuthMethod: m.AuthMethod, - ConnectMode: m.ConnectMode, - Workspace: m.Workspace, - RPM: m.RPM, - MaxTokensField: m.MaxTokensField, - RequestTimeout: m.RequestTimeout, - ThinkingLevel: m.ThinkingLevel, - ExtraBody: m.ExtraBody, - CustomHeaders: m.CustomHeaders, - UserAgent: m.UserAgent, - APIKeys: SimpleSecureStrings(keys[0]), + ModelName: originalName, + Provider: m.Provider, + Model: m.Model, + APIBase: m.APIBase, + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + ToolSchemaTransform: m.ToolSchemaTransform, + ExtraBody: m.ExtraBody, + CustomHeaders: m.CustomHeaders, + UserAgent: m.UserAgent, + APIKeys: SimpleSecureStrings(keys[0]), } // Prepend new fallbacks to existing ones @@ -1548,6 +1559,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.Message.Enabled case "read_file": return t.ReadFile.Enabled + case "serial": + return t.Serial.Enabled case "spawn": return t.Spawn.Enabled case "spawn_status": diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d455572eb..4f1c5c5e8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1998,6 +1998,36 @@ func TestModelConfig_CustomHeadersRoundTrip(t *testing.T) { } } +func TestModelConfig_ToolSchemaTransformRoundTrip(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + cfg := &Config{ + Version: CurrentVersion, + ModelList: []*ModelConfig{ + { + ModelName: "test-model", + Model: "openai/test", + APIKeys: SimpleSecureStrings("sk-test"), + ToolSchemaTransform: "simple", + }, + }, + } + + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + loaded, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + + if got := loaded.ModelList[0].ToolSchemaTransform; got != "simple" { + t.Fatalf("ToolSchemaTransform = %q, want %q", got, "simple") + } +} + func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { cfg := DefaultConfig() diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index f3aaca7ab..8e2494ae5 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -39,7 +39,9 @@ func DefaultConfig() *Config { MaxArgsLength: 300, SeparateMessages: false, }, - SplitOnMarker: false, + SplitOnMarker: false, + MaxLLMRetries: 2, + LLMRetryBackoffSecs: 2, }, }, Session: SessionConfig{ @@ -294,6 +296,9 @@ func DefaultConfig() *Config { HotReload: false, LogLevel: DefaultGatewayLogLevel, }, + Events: EventsConfig{ + Logging: defaultEventLoggingConfig(), + }, Tools: ToolsConfig{ FilterSensitiveData: true, FilterMinLength: 8, @@ -435,6 +440,9 @@ func DefaultConfig() *Config { Mode: ReadFileModeBytes, MaxReadFileSize: 64 * 1024, // 64KB }, + Serial: ToolConfig{ + Enabled: false, // Hardware tool - requires host serial ports + }, Spawn: ToolConfig{ Enabled: true, }, diff --git a/pkg/config/events.go b/pkg/config/events.go new file mode 100644 index 000000000..54a2ce709 --- /dev/null +++ b/pkg/config/events.go @@ -0,0 +1,48 @@ +package config + +// EventsConfig groups runtime event configuration. +type EventsConfig struct { + Logging EventLoggingConfig `json:"logging,omitempty" envPrefix:"PICOCLAW_EVENTS_LOGGING_"` +} + +// EventLoggingConfig controls centralized runtime event logging. +type EventLoggingConfig struct { + // Enabled controls whether runtime events are printed by the built-in logger. + Enabled bool `json:"enabled" env:"ENABLED"` + // Include contains exact event kinds or glob patterns such as "agent.*" or "*". + Include []string `json:"include,omitempty" env:"INCLUDE"` + // Exclude contains exact event kinds or glob patterns to suppress after Include matches. + Exclude []string `json:"exclude,omitempty" env:"EXCLUDE"` + // MinSeverity filters out events below the configured severity: debug, info, warn, or error. + MinSeverity string `json:"min_severity,omitempty" env:"MIN_SEVERITY"` + // IncludePayload adds the raw payload to logs. Leave disabled unless detailed diagnostics are needed. + IncludePayload bool `json:"include_payload,omitempty" env:"INCLUDE_PAYLOAD"` +} + +// DefaultEventLoggingInclude keeps the pre-existing behavior where agent events +// are printed, while non-agent runtime events are published for subscribers only. +var DefaultEventLoggingInclude = []string{"agent.*"} + +// EffectiveEventLoggingConfig returns a logging config with stable defaults. +func EffectiveEventLoggingConfig(cfg *Config) EventLoggingConfig { + if cfg == nil { + return defaultEventLoggingConfig() + } + + out := cfg.Events.Logging + if out.MinSeverity == "" { + out.MinSeverity = "info" + } + if len(out.Include) == 0 { + out.Include = append([]string(nil), DefaultEventLoggingInclude...) + } + return out +} + +func defaultEventLoggingConfig() EventLoggingConfig { + return EventLoggingConfig{ + Enabled: true, + Include: append([]string(nil), DefaultEventLoggingInclude...), + MinSeverity: "info", + } +} diff --git a/pkg/config/events_test.go b/pkg/config/events_test.go new file mode 100644 index 000000000..6cd410492 --- /dev/null +++ b/pkg/config/events_test.go @@ -0,0 +1,103 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDefaultEventLoggingConfig(t *testing.T) { + cfg := DefaultConfig() + logCfg := EffectiveEventLoggingConfig(cfg) + + if !logCfg.Enabled { + t.Fatal("default event logging should be enabled") + } + if !reflect.DeepEqual(logCfg.Include, []string{"agent.*"}) { + t.Fatalf("default include = %#v, want agent.*", logCfg.Include) + } + if logCfg.MinSeverity != "info" { + t.Fatalf("default min severity = %q, want info", logCfg.MinSeverity) + } + if logCfg.IncludePayload { + t.Fatal("default event logging should not include raw payloads") + } +} + +func TestLoadConfigEventLoggingOverrides(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + data := []byte(`{ + "version": 3, + "events": { + "logging": { + "enabled": false, + "include": ["gateway.*"], + "exclude": ["gateway.ready"], + "min_severity": "warn", + "include_payload": true + } + } + }`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + logCfg := EffectiveEventLoggingConfig(cfg) + + if logCfg.Enabled { + t.Fatal("loaded event logging enabled = true, want false") + } + if !reflect.DeepEqual(logCfg.Include, []string{"gateway.*"}) { + t.Fatalf("loaded include = %#v, want gateway.*", logCfg.Include) + } + if !reflect.DeepEqual(logCfg.Exclude, []string{"gateway.ready"}) { + t.Fatalf("loaded exclude = %#v, want gateway.ready", logCfg.Exclude) + } + if logCfg.MinSeverity != "warn" { + t.Fatalf("loaded min severity = %q, want warn", logCfg.MinSeverity) + } + if !logCfg.IncludePayload { + t.Fatal("loaded include_payload = false, want true") + } +} + +func TestLoadConfigEventLoggingEnvOverrides(t *testing.T) { + t.Setenv("PICOCLAW_EVENTS_LOGGING_ENABLED", "false") + t.Setenv("PICOCLAW_EVENTS_LOGGING_INCLUDE", "gateway.*,channel.lifecycle.*") + t.Setenv("PICOCLAW_EVENTS_LOGGING_EXCLUDE", "gateway.ready") + t.Setenv("PICOCLAW_EVENTS_LOGGING_MIN_SEVERITY", "error") + t.Setenv("PICOCLAW_EVENTS_LOGGING_INCLUDE_PAYLOAD", "true") + + path := filepath.Join(t.TempDir(), "config.json") + data := []byte(`{"version": 3}`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + logCfg := EffectiveEventLoggingConfig(cfg) + + if logCfg.Enabled { + t.Fatal("env enabled override = true, want false") + } + if !reflect.DeepEqual(logCfg.Include, []string{"gateway.*", "channel.lifecycle.*"}) { + t.Fatalf("env include = %#v, want gateway/channel lifecycle", logCfg.Include) + } + if !reflect.DeepEqual(logCfg.Exclude, []string{"gateway.ready"}) { + t.Fatalf("env exclude = %#v, want gateway.ready", logCfg.Exclude) + } + if logCfg.MinSeverity != "error" { + t.Fatalf("env min severity = %q, want error", logCfg.MinSeverity) + } + if !logCfg.IncludePayload { + t.Fatal("env include_payload = false, want true") + } +} diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index 8fd501155..d22eb290f 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -158,6 +158,15 @@ func TestModelConfig_Validate(t *testing.T) { }, wantErr: false, }, + { + name: "valid tool schema transform", + config: ModelConfig{ + ModelName: "test", + Model: "openai/gpt-4o", + ToolSchemaTransform: "simple", + }, + wantErr: false, + }, { name: "missing model_name", config: ModelConfig{ @@ -177,6 +186,15 @@ func TestModelConfig_Validate(t *testing.T) { config: ModelConfig{}, wantErr: true, }, + { + name: "invalid tool schema transform", + config: ModelConfig{ + ModelName: "test", + Model: "openai/gpt-4o", + ToolSchemaTransform: "invalid", + }, + wantErr: true, + }, } for _, tt := range tests { diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index cb55db938..073cb7826 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -187,15 +187,16 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) { func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { modelCfg := &ModelConfig{ - ModelName: "gpt-4", - Provider: "openrouter", - Model: "openai/gpt-4o", - APIBase: "https://api.example.com", - Proxy: "http://proxy:8080", - RPM: 60, - MaxTokensField: "max_completion_tokens", - RequestTimeout: 30, - ThinkingLevel: "high", + ModelName: "gpt-4", + Provider: "openrouter", + Model: "openai/gpt-4o", + APIBase: "https://api.example.com", + Proxy: "http://proxy:8080", + RPM: 60, + MaxTokensField: "max_completion_tokens", + RequestTimeout: 30, + ThinkingLevel: "high", + ToolSchemaTransform: "simple", } modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing models := []*ModelConfig{modelCfg} @@ -225,6 +226,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { if primary.ThinkingLevel != "high" { t.Errorf("expected thinking_level preserved, got %q", primary.ThinkingLevel) } + if primary.ToolSchemaTransform != "simple" { + t.Errorf("expected tool_schema_transform preserved, got %q", primary.ToolSchemaTransform) + } // Check additional entry also preserves fields additional := result[0] @@ -237,6 +241,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { if additional.RPM != 60 { t.Errorf("expected additional rpm preserved, got %d", additional.RPM) } + if additional.ToolSchemaTransform != "simple" { + t.Errorf("expected additional tool_schema_transform preserved, got %q", additional.ToolSchemaTransform) + } } func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) { diff --git a/pkg/events/bus.go b/pkg/events/bus.go new file mode 100644 index 000000000..f193ccb74 --- /dev/null +++ b/pkg/events/bus.go @@ -0,0 +1,243 @@ +package events + +import ( + "context" + "sort" + "strconv" + "sync" + "sync/atomic" + "time" +) + +var globalEventSeq atomic.Uint64 + +// Bus publishes runtime events and creates filtered channels. +type Bus interface { + Publish(ctx context.Context, evt Event) PublishResult + PublishNonBlocking(evt Event) PublishResult + Channel() EventChannel + Close() error + Stats() Stats +} + +// PublishResult reports per-publish delivery outcomes. +type PublishResult struct { + Matched int + Delivered int + Dropped int + Blocked int + Closed bool +} + +// EventBus is an in-process runtime event broadcaster. +type EventBus struct { + mu sync.RWMutex + subs map[uint64]*eventSubscription + orderedSubs []*eventSubscription + closed bool + + nextSubID atomic.Uint64 + published atomic.Uint64 + matched atomic.Uint64 + delivered atomic.Uint64 + dropped atomic.Uint64 + blocked atomic.Uint64 +} + +var _ Bus = (*EventBus)(nil) + +// NewBus creates an in-process runtime event bus. +func NewBus() *EventBus { + return &EventBus{ + subs: make(map[uint64]*eventSubscription), + } +} + +// Publish broadcasts evt to subscriptions whose filters match it. +func (b *EventBus) Publish(ctx context.Context, evt Event) PublishResult { + return b.publish(ctx, evt, false) +} + +// PublishNonBlocking broadcasts evt without waiting for subscriber queue capacity. +func (b *EventBus) PublishNonBlocking(evt Event) PublishResult { + return b.publish(context.Background(), evt, true) +} + +func (b *EventBus) publish(ctx context.Context, evt Event, nonBlocking bool) PublishResult { + if b == nil { + return PublishResult{Closed: true} + } + if ctx == nil { + ctx = context.Background() + } + if evt.Time.IsZero() { + evt.Time = time.Now() + } + if evt.ID == "" { + evt.ID = nextEventID() + } + + subs, closed := b.snapshotSubscribers() + if closed { + return PublishResult{Closed: true} + } + + b.published.Add(1) + result := PublishResult{} + + for _, sub := range subs { + if !matchesFilters(sub.filters, evt) { + continue + } + + result.Matched++ + b.matched.Add(1) + + delivery := sub.enqueue(ctx, evt, nonBlocking) + if delivery.closed { + continue + } + result.Delivered += delivery.delivered + result.Dropped += delivery.dropped + result.Blocked += delivery.blocked + b.delivered.Add(uint64(delivery.delivered)) + b.dropped.Add(uint64(delivery.dropped)) + b.blocked.Add(uint64(delivery.blocked)) + } + + return result +} + +// Channel returns the root event channel for this bus. +func (b *EventBus) Channel() EventChannel { + return eventChannel{bus: b} +} + +// Close closes the bus and all active subscriptions. +func (b *EventBus) Close() error { + if b == nil { + return nil + } + + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return nil + } + b.closed = true + subs := b.orderedSubs + b.subs = nil + b.orderedSubs = nil + b.mu.Unlock() + + for _, sub := range subs { + sub.closeInput() + } + return nil +} + +// Stats returns a snapshot of bus and subscription counters. +func (b *EventBus) Stats() Stats { + if b == nil { + return Stats{Closed: true} + } + + b.mu.RLock() + closed := b.closed + subs := b.orderedSubs + b.mu.RUnlock() + + stats := Stats{ + Published: b.published.Load(), + Matched: b.matched.Load(), + Delivered: b.delivered.Load(), + Dropped: b.dropped.Load(), + Blocked: b.blocked.Load(), + Closed: closed, + Subscribers: len(subs), + SubscriberStats: make([]SubscriberStats, 0, len(subs)), + } + for _, sub := range subs { + stats.SubscriberStats = append(stats.SubscriberStats, sub.Stats()) + } + return stats +} + +func (b *EventBus) subscribe( + ctx context.Context, + filters []Filter, + opts SubscribeOptions, + handler Handler, + once bool, +) (Subscription, error) { + if b == nil { + return nil, ErrBusClosed + } + + id := b.nextSubID.Add(1) + sub := newSubscription(b, id, filters, opts, handler, once) + + b.mu.Lock() + if b.closed { + b.mu.Unlock() + sub.closeInput() + return nil, ErrBusClosed + } + b.subs[id] = sub + b.rebuildOrderedSubscribersLocked() + b.mu.Unlock() + + if handler != nil { + go sub.run(ctx) + } + sub.watchContext(ctx) + return sub, nil +} + +func (b *EventBus) unsubscribe(id uint64) { + b.mu.Lock() + sub, ok := b.subs[id] + if ok { + delete(b.subs, id) + b.rebuildOrderedSubscribersLocked() + } + b.mu.Unlock() + + if ok { + sub.closeInput() + } +} + +func (b *EventBus) snapshotSubscribers() ([]*eventSubscription, bool) { + b.mu.RLock() + defer b.mu.RUnlock() + + if b.closed { + return nil, true + } + + return b.orderedSubs, false +} + +func (b *EventBus) rebuildOrderedSubscribersLocked() { + subs := make([]*eventSubscription, 0, len(b.subs)) + for _, sub := range b.subs { + subs = append(subs, sub) + } + sortSubscriptions(subs) + b.orderedSubs = subs +} + +func sortSubscriptions(subs []*eventSubscription) { + sort.Slice(subs, func(i, j int) bool { + if subs[i].opts.Priority == subs[j].opts.Priority { + return subs[i].id < subs[j].id + } + return subs[i].opts.Priority > subs[j].opts.Priority + }) +} + +func nextEventID() string { + id := globalEventSeq.Add(1) + return "evt-" + strconv.FormatUint(id, 10) +} diff --git a/pkg/events/channel.go b/pkg/events/channel.go new file mode 100644 index 000000000..9cf6d8d8c --- /dev/null +++ b/pkg/events/channel.go @@ -0,0 +1,75 @@ +package events + +import "context" + +// EventChannel is a filtered view over an EventBus. +type EventChannel interface { + Filter(filter Filter) EventChannel + OfKind(kinds ...Kind) EventChannel + KindPrefix(prefix string) EventChannel + Source(component string, names ...string) EventChannel + Scope(scope ScopeFilter) EventChannel + + Subscribe(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) + SubscribeChan(ctx context.Context, opts SubscribeOptions) (Subscription, <-chan Event, error) + SubscribeOnce(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) +} + +type eventChannel struct { + bus *EventBus + filters []Filter +} + +// Filter returns a new EventChannel with filter appended. +func (c eventChannel) Filter(filter Filter) EventChannel { + filters := append([]Filter(nil), c.filters...) + if filter != nil { + filters = append(filters, filter) + } + return eventChannel{bus: c.bus, filters: filters} +} + +// OfKind returns a new EventChannel matching any of kinds. +func (c eventChannel) OfKind(kinds ...Kind) EventChannel { + return c.Filter(MatchKind(kinds...)) +} + +// KindPrefix returns a new EventChannel matching events with the kind prefix. +func (c eventChannel) KindPrefix(prefix string) EventChannel { + return c.Filter(MatchKindPrefix(prefix)) +} + +// Source returns a new EventChannel matching source component and optional names. +func (c eventChannel) Source(component string, names ...string) EventChannel { + return c.Filter(MatchSource(component, names...)) +} + +// Scope returns a new EventChannel matching non-empty scope fields. +func (c eventChannel) Scope(scope ScopeFilter) EventChannel { + return c.Filter(MatchScope(scope)) +} + +// Subscribe registers handler for events matching this channel. +func (c eventChannel) Subscribe(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) { + if handler == nil { + return nil, ErrNilHandler + } + return c.bus.subscribe(ctx, c.filters, opts, handler, false) +} + +// SubscribeChan registers a channel subscription for events matching this channel. +func (c eventChannel) SubscribeChan(ctx context.Context, opts SubscribeOptions) (Subscription, <-chan Event, error) { + sub, err := c.bus.subscribe(ctx, c.filters, opts, nil, false) + if err != nil { + return nil, nil, err + } + return sub, sub.(*eventSubscription).ch, nil +} + +// SubscribeOnce registers handler and closes the subscription after the first event. +func (c eventChannel) SubscribeOnce(ctx context.Context, opts SubscribeOptions, handler Handler) (Subscription, error) { + if handler == nil { + return nil, ErrNilHandler + } + return c.bus.subscribe(ctx, c.filters, opts, handler, true) +} diff --git a/pkg/events/doc.go b/pkg/events/doc.go new file mode 100644 index 000000000..dc2f55631 --- /dev/null +++ b/pkg/events/doc.go @@ -0,0 +1,3 @@ +// Package events provides the process-local runtime event bus used to observe +// PicoClaw components without coupling them to agent-specific event envelopes. +package events diff --git a/pkg/events/events_test.go b/pkg/events/events_test.go new file mode 100644 index 000000000..6991e8291 --- /dev/null +++ b/pkg/events/events_test.go @@ -0,0 +1,254 @@ +package events + +import ( + "context" + "testing" + "time" +) + +func TestPublishDeliversToMatchingSubscriber(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + _, ch, err := bus.Channel().OfKind(KindAgentTurnStart).SubscribeChan( + context.Background(), + SubscribeOptions{Name: "turn-starts", Buffer: 1}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + unmatched := bus.Publish(context.Background(), Event{Kind: KindAgentTurnEnd}) + if unmatched.Matched != 0 || unmatched.Delivered != 0 { + t.Fatalf("unmatched Publish = %+v, want no delivery", unmatched) + } + + result := bus.Publish(context.Background(), Event{Kind: KindAgentTurnStart}) + if result.Matched != 1 || result.Delivered != 1 || result.Dropped != 0 { + t.Fatalf("Publish = %+v, want one delivered event", result) + } + + evt := receiveEvent(t, ch) + if evt.Kind != KindAgentTurnStart { + t.Fatalf("event kind = %q, want %q", evt.Kind, KindAgentTurnStart) + } + if evt.ID == "" { + t.Fatal("event ID is empty") + } + if evt.Time.IsZero() { + t.Fatal("event Time is zero") + } +} + +func TestDropNewestIncrementsStats(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "drop-newest", Buffer: 1, Backpressure: DropNewest}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.Publish(context.Background(), Event{Kind: KindAgentTurnStart}) + if first.Delivered != 1 || first.Dropped != 0 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + + second := bus.Publish(context.Background(), Event{Kind: KindAgentTurnEnd}) + if second.Delivered != 0 || second.Dropped != 1 { + t.Fatalf("second Publish = %+v, want one dropped event", second) + } + + if got := sub.Stats().Dropped; got != 1 { + t.Fatalf("subscription dropped = %d, want 1", got) + } + if got := bus.Stats().Dropped; got != 1 { + t.Fatalf("bus dropped = %d, want 1", got) + } +} + +func TestDropOldestKeepsNewestEvent(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, ch, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "drop-oldest", Buffer: 1, Backpressure: DropOldest}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: Kind("test.old"), Payload: "old"}) + result := bus.Publish(context.Background(), Event{Kind: Kind("test.new"), Payload: "new"}) + if result.Delivered != 1 || result.Dropped != 1 { + t.Fatalf("Publish = %+v, want replacement delivery", result) + } + + evt := receiveEvent(t, ch) + if evt.Payload != "new" { + t.Fatalf("payload = %v, want new", evt.Payload) + } + if got := sub.Stats().Dropped; got != 1 { + t.Fatalf("subscription dropped = %d, want 1", got) + } +} + +func TestBlockRespectsContext(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + _, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "block", Buffer: 1, Backpressure: Block}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.Publish(context.Background(), Event{Kind: Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + second := bus.Publish(ctx, Event{Kind: Kind("test.second")}) + if second.Blocked != 1 || second.Dropped != 1 || second.Delivered != 0 { + t.Fatalf("second Publish = %+v, want one blocked drop", second) + } +} + +func TestPublishNonBlockingDropsForFullBlockSubscriber(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "block", Buffer: 1, Backpressure: Block}, + ) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.PublishNonBlocking(Event{Kind: Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first PublishNonBlocking = %+v, want one delivered event", first) + } + + resultCh := make(chan PublishResult, 1) + go func() { + resultCh <- bus.PublishNonBlocking(Event{Kind: Kind("test.second")}) + }() + + select { + case second := <-resultCh: + if second.Matched != 1 || second.Delivered != 0 || second.Dropped != 1 || second.Blocked != 0 { + t.Fatalf("second PublishNonBlocking = %+v, want non-blocking drop", second) + } + case <-time.After(100 * time.Millisecond): + t.Fatal("PublishNonBlocking blocked on full Block subscriber") + } + + if got := sub.Stats().Dropped; got != 1 { + t.Fatalf("subscription dropped = %d, want 1", got) + } +} + +func TestStatsSubscribersKeepPriorityOrder(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + low, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "low", Priority: -1}, + ) + if err != nil { + t.Fatalf("SubscribeChan low failed: %v", err) + } + high, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "high", Priority: 10}, + ) + if err != nil { + t.Fatalf("SubscribeChan high failed: %v", err) + } + peer, _, err := bus.Channel().SubscribeChan( + context.Background(), + SubscribeOptions{Name: "peer", Priority: 10}, + ) + if err != nil { + t.Fatalf("SubscribeChan peer failed: %v", err) + } + + stats := bus.Stats() + got := []string{ + stats.SubscriberStats[0].Name, + stats.SubscriberStats[1].Name, + stats.SubscriberStats[2].Name, + } + want := []string{"high", "peer", "low"} + if got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { + t.Fatalf("subscriber order = %v, want %v", got, want) + } + + if err := high.Close(); err != nil { + t.Fatalf("Close high failed: %v", err) + } + + stats = bus.Stats() + got = []string{ + stats.SubscriberStats[0].Name, + stats.SubscriberStats[1].Name, + } + want = []string{"peer", "low"} + if got[0] != want[0] || got[1] != want[1] { + t.Fatalf("subscriber order after unsubscribe = %v, want %v", got, want) + } + + if err := peer.Close(); err != nil { + t.Fatalf("Close peer failed: %v", err) + } + if err := low.Close(); err != nil { + t.Fatalf("Close low failed: %v", err) + } +} + +func receiveEvent(t *testing.T, ch <-chan Event) Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("event channel closed before receive") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + return Event{} + } +} + +func closeBus(t *testing.T, bus *EventBus) { + t.Helper() + + if err := bus.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } +} diff --git a/pkg/events/filter.go b/pkg/events/filter.go new file mode 100644 index 000000000..0af2c85c1 --- /dev/null +++ b/pkg/events/filter.go @@ -0,0 +1,131 @@ +package events + +import "strings" + +// Filter decides whether an event should pass through an EventChannel. +type Filter func(Event) bool + +// ScopeFilter matches selected non-empty fields against Event.Scope. +type ScopeFilter struct { + AgentID string + SessionKey string + TurnID string + Channel string + ChatID string + MessageID string +} + +// MatchKind matches events whose kind is in kinds. Empty kinds match all events. +func MatchKind(kinds ...Kind) Filter { + if len(kinds) == 0 { + return matchAll + } + + allowed := make(map[Kind]struct{}, len(kinds)) + for _, kind := range kinds { + allowed[kind] = struct{}{} + } + + return func(evt Event) bool { + _, ok := allowed[evt.Kind] + return ok + } +} + +// MatchKindPrefix matches events whose kind starts with prefix. +func MatchKindPrefix(prefix string) Filter { + if prefix == "" { + return matchAll + } + return func(evt Event) bool { + return strings.HasPrefix(evt.Kind.String(), prefix) + } +} + +// MatchSource matches events emitted by component and, optionally, one of names. +func MatchSource(component string, names ...string) Filter { + if component == "" && len(names) == 0 { + return matchAll + } + + allowedNames := make(map[string]struct{}, len(names)) + for _, name := range names { + allowedNames[name] = struct{}{} + } + + return func(evt Event) bool { + if component != "" && evt.Source.Component != component { + return false + } + if len(allowedNames) == 0 { + return true + } + _, ok := allowedNames[evt.Source.Name] + return ok + } +} + +// MatchScope matches events whose Scope contains all non-empty filter fields. +func MatchScope(scope ScopeFilter) Filter { + if scope == (ScopeFilter{}) { + return matchAll + } + + return func(evt Event) bool { + return matchesString(scope.AgentID, evt.Scope.AgentID) && + matchesString(scope.SessionKey, evt.Scope.SessionKey) && + matchesString(scope.TurnID, evt.Scope.TurnID) && + matchesString(scope.Channel, evt.Scope.Channel) && + matchesString(scope.ChatID, evt.Scope.ChatID) && + matchesString(scope.MessageID, evt.Scope.MessageID) + } +} + +// And combines filters and short-circuits on the first non-match. +func And(filters ...Filter) Filter { + if len(filters) == 0 { + return matchAll + } + + return func(evt Event) bool { + for _, filter := range filters { + if filter != nil && !filter(evt) { + return false + } + } + return true + } +} + +// Or combines filters and short-circuits on the first match. +func Or(filters ...Filter) Filter { + if len(filters) == 0 { + return matchAll + } + + return func(evt Event) bool { + for _, filter := range filters { + if filter == nil || filter(evt) { + return true + } + } + return false + } +} + +func matchAll(Event) bool { + return true +} + +func matchesString(want, got string) bool { + return want == "" || want == got +} + +func matchesFilters(filters []Filter, evt Event) bool { + for _, filter := range filters { + if filter != nil && !filter(evt) { + return false + } + } + return true +} diff --git a/pkg/events/filter_test.go b/pkg/events/filter_test.go new file mode 100644 index 000000000..9b0112754 --- /dev/null +++ b/pkg/events/filter_test.go @@ -0,0 +1,96 @@ +package events + +import "testing" + +func TestFilterKindPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prefix string + event Event + want bool + }{ + { + name: "matches agent prefix", + prefix: "agent.", + event: Event{Kind: KindAgentTurnStart}, + want: true, + }, + { + name: "rejects different prefix", + prefix: "channel.", + event: Event{Kind: KindAgentTurnStart}, + want: false, + }, + { + name: "empty prefix matches all", + prefix: "", + event: Event{Kind: KindAgentTurnStart}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := MatchKindPrefix(tt.prefix)(tt.event); got != tt.want { + t.Fatalf("MatchKindPrefix(%q) = %v, want %v", tt.prefix, got, tt.want) + } + }) + } +} + +func TestFilterScope(t *testing.T) { + t.Parallel() + + evt := Event{ + Scope: Scope{ + AgentID: "agent-a", + SessionKey: "session-1", + TurnID: "turn-1", + Channel: "telegram", + ChatID: "chat-1", + MessageID: "msg-1", + }, + } + + tests := []struct { + name string + scope ScopeFilter + want bool + }{ + { + name: "empty filter matches", + scope: ScopeFilter{}, + want: true, + }, + { + name: "matches selected fields", + scope: ScopeFilter{ + AgentID: "agent-a", + ChatID: "chat-1", + }, + want: true, + }, + { + name: "rejects mismatched field", + scope: ScopeFilter{ + AgentID: "agent-a", + MessageID: "msg-2", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := MatchScope(tt.scope)(evt); got != tt.want { + t.Fatalf("MatchScope(%+v) = %v, want %v", tt.scope, got, tt.want) + } + }) + } +} diff --git a/pkg/events/kind.go b/pkg/events/kind.go new file mode 100644 index 000000000..b9327e155 --- /dev/null +++ b/pkg/events/kind.go @@ -0,0 +1,156 @@ +package events + +const ( + // KindAgentTurnStart is emitted when an agent turn starts. + KindAgentTurnStart Kind = "agent.turn.start" + // KindAgentTurnEnd is emitted when an agent turn ends. + KindAgentTurnEnd Kind = "agent.turn.end" + + // KindAgentLLMRequest is emitted before an LLM request. + KindAgentLLMRequest Kind = "agent.llm.request" + // KindAgentLLMDelta is emitted for streaming LLM deltas. + KindAgentLLMDelta Kind = "agent.llm.delta" + // KindAgentLLMResponse is emitted after an LLM response. + KindAgentLLMResponse Kind = "agent.llm.response" + // KindAgentLLMRetry is emitted before retrying an LLM request. + KindAgentLLMRetry Kind = "agent.llm.retry" + + // KindAgentContextCompress is emitted when agent context is compressed. + KindAgentContextCompress Kind = "agent.context.compress" + // KindAgentSessionSummarize is emitted when session summarization completes. + KindAgentSessionSummarize Kind = "agent.session.summarize" + + // KindAgentToolExecStart is emitted before a tool executes. + KindAgentToolExecStart Kind = "agent.tool.exec_start" + // KindAgentToolExecEnd is emitted after a tool finishes. + KindAgentToolExecEnd Kind = "agent.tool.exec_end" + // KindAgentToolExecSkipped is emitted when a tool call is skipped. + KindAgentToolExecSkipped Kind = "agent.tool.exec_skipped" + + // KindAgentSteeringInjected is emitted when steering is injected into context. + KindAgentSteeringInjected Kind = "agent.steering.injected" + // KindAgentFollowUpQueued is emitted when async follow-up input is queued. + KindAgentFollowUpQueued Kind = "agent.follow_up.queued" + // KindAgentInterruptReceived is emitted when a turn interrupt is accepted. + KindAgentInterruptReceived Kind = "agent.interrupt.received" + + // KindAgentSubTurnSpawn is emitted when a sub-turn is spawned. + KindAgentSubTurnSpawn Kind = "agent.subturn.spawn" + // KindAgentSubTurnEnd is emitted when a sub-turn ends. + KindAgentSubTurnEnd Kind = "agent.subturn.end" + // KindAgentSubTurnResultDelivered is emitted when a sub-turn result is delivered. + KindAgentSubTurnResultDelivered Kind = "agent.subturn.result_delivered" + // KindAgentSubTurnOrphan is emitted when a sub-turn result cannot be delivered. + KindAgentSubTurnOrphan Kind = "agent.subturn.orphan" + // KindAgentError is emitted when agent execution reports an error. + KindAgentError Kind = "agent.error" + + // KindChannelLifecycleStarted is emitted when a channel starts. + KindChannelLifecycleStarted Kind = "channel.lifecycle.started" + // KindChannelLifecycleInitialized is emitted when a channel is initialized. + KindChannelLifecycleInitialized Kind = "channel.lifecycle.initialized" + // KindChannelLifecycleStartFailed is emitted when a channel fails to start. + KindChannelLifecycleStartFailed Kind = "channel.lifecycle.start_failed" + // KindChannelLifecycleStopped is emitted when a channel stops. + KindChannelLifecycleStopped Kind = "channel.lifecycle.stopped" + // KindChannelWebhookRegistered is emitted when a channel webhook is registered. + KindChannelWebhookRegistered Kind = "channel.webhook.registered" + // KindChannelWebhookUnregistered is emitted when a channel webhook is unregistered. + KindChannelWebhookUnregistered Kind = "channel.webhook.unregistered" + // KindChannelMessageOutboundQueued is emitted when an outbound message is queued. + KindChannelMessageOutboundQueued Kind = "channel.message.outbound_queued" + // KindChannelMessageOutboundSent is emitted when an outbound channel message is sent. + KindChannelMessageOutboundSent Kind = "channel.message.outbound_sent" + // KindChannelMessageOutboundFailed is emitted when an outbound channel message fails. + KindChannelMessageOutboundFailed Kind = "channel.message.outbound_failed" + // KindChannelRateLimited is emitted when channel rate limiting blocks delivery. + KindChannelRateLimited Kind = "channel.rate_limited" + + // KindBusPublishFailed is emitted when message bus publish fails. + KindBusPublishFailed Kind = "bus.publish.failed" + // KindBusCloseStarted is emitted when message bus close starts. + KindBusCloseStarted Kind = "bus.close.started" + // KindBusCloseCompleted is emitted when message bus close completes. + KindBusCloseCompleted Kind = "bus.close.completed" + // KindBusCloseDrained is emitted when message bus close drains buffered messages. + KindBusCloseDrained Kind = "bus.close.drained" + + // KindGatewayStart is emitted when gateway startup reaches runtime bootstrap. + KindGatewayStart Kind = "gateway.start" + // KindGatewayReady is emitted when gateway services are started and ready. + KindGatewayReady Kind = "gateway.ready" + // KindGatewayShutdown is emitted when gateway shutdown starts. + KindGatewayShutdown Kind = "gateway.shutdown" + // KindGatewayReloadStarted is emitted when gateway reload starts. + KindGatewayReloadStarted Kind = "gateway.reload.started" + // KindGatewayReloadCompleted is emitted when gateway reload completes. + KindGatewayReloadCompleted Kind = "gateway.reload.completed" + // KindGatewayReloadFailed is emitted when gateway reload fails. + KindGatewayReloadFailed Kind = "gateway.reload.failed" + + // KindMCPServerConnected is emitted when an MCP server connects. + KindMCPServerConnected Kind = "mcp.server.connected" + // KindMCPServerConnecting is emitted before connecting to an MCP server. + KindMCPServerConnecting Kind = "mcp.server.connecting" + // KindMCPServerFailed is emitted when an MCP server fails. + KindMCPServerFailed Kind = "mcp.server.failed" + // KindMCPToolDiscovered is emitted when an MCP tool is discovered. + KindMCPToolDiscovered Kind = "mcp.tool.discovered" + // KindMCPToolCallStart is emitted when an MCP tool call starts. + KindMCPToolCallStart Kind = "mcp.tool.call.start" + // KindMCPToolCallEnd is emitted when an MCP tool call ends. + KindMCPToolCallEnd Kind = "mcp.tool.call.end" +) + +var knownKinds = []Kind{ + KindAgentTurnStart, + KindAgentTurnEnd, + KindAgentLLMRequest, + KindAgentLLMDelta, + KindAgentLLMResponse, + KindAgentLLMRetry, + KindAgentContextCompress, + KindAgentSessionSummarize, + KindAgentToolExecStart, + KindAgentToolExecEnd, + KindAgentToolExecSkipped, + KindAgentSteeringInjected, + KindAgentFollowUpQueued, + KindAgentInterruptReceived, + KindAgentSubTurnSpawn, + KindAgentSubTurnEnd, + KindAgentSubTurnResultDelivered, + KindAgentSubTurnOrphan, + KindAgentError, + KindChannelLifecycleStarted, + KindChannelLifecycleInitialized, + KindChannelLifecycleStartFailed, + KindChannelLifecycleStopped, + KindChannelWebhookRegistered, + KindChannelWebhookUnregistered, + KindChannelMessageOutboundQueued, + KindChannelMessageOutboundSent, + KindChannelMessageOutboundFailed, + KindChannelRateLimited, + KindBusPublishFailed, + KindBusCloseStarted, + KindBusCloseCompleted, + KindBusCloseDrained, + KindGatewayStart, + KindGatewayReady, + KindGatewayShutdown, + KindGatewayReloadStarted, + KindGatewayReloadCompleted, + KindGatewayReloadFailed, + KindMCPServerConnected, + KindMCPServerConnecting, + KindMCPServerFailed, + KindMCPToolDiscovered, + KindMCPToolCallStart, + KindMCPToolCallEnd, +} + +// KnownKinds returns the runtime event kinds declared by this package. +func KnownKinds() []Kind { + return append([]Kind(nil), knownKinds...) +} diff --git a/pkg/events/stats.go b/pkg/events/stats.go new file mode 100644 index 000000000..7931c5ef3 --- /dev/null +++ b/pkg/events/stats.go @@ -0,0 +1,26 @@ +package events + +// Stats reports aggregate EventBus counters. +type Stats struct { + Published uint64 + Matched uint64 + Delivered uint64 + Dropped uint64 + Blocked uint64 + Closed bool + Subscribers int + + SubscriberStats []SubscriberStats +} + +// SubscriberStats reports counters for one subscription. +type SubscriberStats struct { + ID uint64 + Name string + Received uint64 + Handled uint64 + Failed uint64 + Dropped uint64 + Panicked uint64 + TimedOut uint64 +} diff --git a/pkg/events/subscription.go b/pkg/events/subscription.go new file mode 100644 index 000000000..6619707a7 --- /dev/null +++ b/pkg/events/subscription.go @@ -0,0 +1,459 @@ +package events + +import ( + "context" + "errors" + "log" + "sync" + "sync/atomic" + "time" +) + +const defaultSubscriberBuffer = 16 + +var ( + // ErrBusClosed is returned when subscribing to a closed event bus. + ErrBusClosed = errors.New("events: bus is closed") + // ErrNilHandler is returned when subscribing without a handler. + ErrNilHandler = errors.New("events: handler is nil") +) + +// Handler processes a runtime event delivered to a subscription. +type Handler func(context.Context, Event) error + +// SubscribeOptions controls how a subscription receives events. +type SubscribeOptions struct { + Name string + Buffer int + Priority int + Concurrency ConcurrencyKind + Backpressure BackpressurePolicy + // Timeout bounds how long the subscription worker waits for one handler call. + // Handlers should still honor ctx cancellation; timed-out calls keep running + // until their handler returns. + Timeout time.Duration + PanicPolicy PanicPolicy +} + +// ConcurrencyKind controls how handler subscriptions process queued events. +type ConcurrencyKind string + +const ( + // Concurrent processes each event in its own goroutine. + Concurrent ConcurrencyKind = "concurrent" + // Locked processes events sequentially in subscription order. + Locked ConcurrencyKind = "locked" + // Keyed is reserved for keyed sequential processing and currently behaves as Locked. + Keyed ConcurrencyKind = "keyed" +) + +// BackpressurePolicy controls delivery when a subscription queue is full. +type BackpressurePolicy string + +const ( + // DropNewest drops the event being published when the queue is full. + DropNewest BackpressurePolicy = "drop_newest" + // DropOldest drops one queued event and enqueues the event being published. + DropOldest BackpressurePolicy = "drop_oldest" + // Block waits for queue capacity until Publish's context is canceled. + Block BackpressurePolicy = "block" +) + +// PanicPolicy controls handler panic behavior. +type PanicPolicy string + +const ( + // RecoverAndLog recovers handler panics and records them in subscription stats. + RecoverAndLog PanicPolicy = "recover_and_log" + // Crash lets handler panics propagate from the worker goroutine. + Crash PanicPolicy = "crash" +) + +// Subscription represents an active event subscription. +type Subscription interface { + ID() uint64 + Name() string + Close() error + Done() <-chan struct{} + Stats() SubscriberStats +} + +type subscriberCounters struct { + received atomic.Uint64 + handled atomic.Uint64 + failed atomic.Uint64 + dropped atomic.Uint64 + panicked atomic.Uint64 + timedOut atomic.Uint64 +} + +type eventSubscription struct { + bus *EventBus + id uint64 + name string + opts SubscribeOptions + filters []Filter + handler Handler + once bool + + ch chan Event + done chan struct{} + closing chan struct{} + + closeOnce sync.Once + doneOnce sync.Once + mu sync.RWMutex + closed bool + wg sync.WaitGroup + blockWG sync.WaitGroup + + counters subscriberCounters +} + +type handlerResult struct { + err error + panicked bool +} + +func normalizeSubscribeOptions(opts SubscribeOptions) SubscribeOptions { + if opts.Buffer <= 0 { + opts.Buffer = defaultSubscriberBuffer + } + if opts.Concurrency == "" { + opts.Concurrency = Locked + } + if opts.Backpressure == "" { + opts.Backpressure = DropNewest + } + if opts.PanicPolicy == "" { + opts.PanicPolicy = RecoverAndLog + } + return opts +} + +func newSubscription( + bus *EventBus, + id uint64, + filters []Filter, + opts SubscribeOptions, + handler Handler, + once bool, +) *eventSubscription { + opts = normalizeSubscribeOptions(opts) + return &eventSubscription{ + bus: bus, + id: id, + name: opts.Name, + opts: opts, + filters: append([]Filter(nil), filters...), + handler: handler, + once: once, + ch: make(chan Event, opts.Buffer), + done: make(chan struct{}), + closing: make(chan struct{}), + } +} + +// ID returns the subscription identifier. +func (s *eventSubscription) ID() uint64 { + if s == nil { + return 0 + } + return s.id +} + +// Name returns the subscription name. +func (s *eventSubscription) Name() string { + if s == nil { + return "" + } + return s.name +} + +// Close removes the subscription and closes its delivery channel. +func (s *eventSubscription) Close() error { + if s == nil || s.bus == nil { + return nil + } + s.bus.unsubscribe(s.id) + return nil +} + +// Done returns a channel closed after the subscription has stopped processing. +func (s *eventSubscription) Done() <-chan struct{} { + if s == nil { + ch := make(chan struct{}) + close(ch) + return ch + } + return s.done +} + +// Stats returns a snapshot of the subscription counters. +func (s *eventSubscription) Stats() SubscriberStats { + if s == nil { + return SubscriberStats{} + } + return SubscriberStats{ + ID: s.id, + Name: s.name, + Received: s.counters.received.Load(), + Handled: s.counters.handled.Load(), + Failed: s.counters.failed.Load(), + Dropped: s.counters.dropped.Load(), + Panicked: s.counters.panicked.Load(), + TimedOut: s.counters.timedOut.Load(), + } +} + +func (s *eventSubscription) run(ctx context.Context) { + defer func() { + s.wg.Wait() + s.closeDone() + }() + + for evt := range s.ch { + s.dispatch(ctx, evt) + if s.once { + _ = s.Close() + return + } + } +} + +func (s *eventSubscription) dispatch(ctx context.Context, evt Event) { + switch s.opts.Concurrency { + case Concurrent: + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.handle(ctx, evt) + }() + case Keyed: + // TODO: replace this with keyed executors when runtime events need + // per-scope ordering with cross-scope concurrency. + s.handle(ctx, evt) + default: + s.handle(ctx, evt) + } +} + +func (s *eventSubscription) handle(ctx context.Context, evt Event) { + if ctx == nil { + ctx = context.Background() + } + + if s.opts.Timeout <= 0 { + s.recordHandlerResult(ctx, s.invokeHandler(ctx, evt)) + return + } + + ctx, cancel := context.WithTimeout(ctx, s.opts.Timeout) + defer cancel() + + done := make(chan handlerResult, 1) + go func() { + done <- s.invokeHandler(ctx, evt) + }() + + select { + case result := <-done: + s.recordHandlerResult(ctx, result) + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + s.counters.timedOut.Add(1) + } + s.counters.failed.Add(1) + } +} + +func (s *eventSubscription) invokeHandler(ctx context.Context, evt Event) (result handlerResult) { + if s.opts.PanicPolicy != Crash { + defer func() { + if recovered := recover(); recovered != nil { + s.counters.panicked.Add(1) + result.panicked = true + log.Printf("events: subscriber %q recovered panic: %v", s.name, recovered) + } + }() + } + + result.err = s.handler(ctx, evt) + return result +} + +func (s *eventSubscription) recordHandlerResult(ctx context.Context, result handlerResult) { + if result.panicked { + return + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + s.counters.timedOut.Add(1) + } + if result.err != nil { + s.counters.failed.Add(1) + return + } + s.counters.handled.Add(1) +} + +func (s *eventSubscription) watchContext(ctx context.Context) { + if ctx == nil { + return + } + + go func() { + select { + case <-ctx.Done(): + _ = s.Close() + case <-s.done: + } + }() +} + +func (s *eventSubscription) closeInput() { + s.closeOnce.Do(func() { + close(s.closing) + s.mu.Lock() + s.closed = true + s.mu.Unlock() + s.blockWG.Wait() + s.mu.Lock() + close(s.ch) + s.mu.Unlock() + if s.handler == nil { + s.closeDone() + } + }) +} + +func (s *eventSubscription) closeDone() { + s.doneOnce.Do(func() { + close(s.done) + }) +} + +type deliveryResult struct { + delivered int + dropped int + blocked int + closed bool +} + +func (s *eventSubscription) enqueue(ctx context.Context, evt Event, nonBlocking bool) deliveryResult { + if ctx == nil { + ctx = context.Background() + } + + if nonBlocking { + return s.enqueueNonBlocking(evt) + } + + if s.opts.Backpressure == Block { + return s.enqueueBlocking(ctx, evt) + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.closed { + return deliveryResult{closed: true} + } + + s.counters.received.Add(1) + + switch s.opts.Backpressure { + case DropOldest: + return s.enqueueDropOldest(evt) + default: + return s.enqueueDropNewest(evt) + } +} + +func (s *eventSubscription) enqueueBlocking(ctx context.Context, evt Event) deliveryResult { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return deliveryResult{closed: true} + } + s.blockWG.Add(1) + s.counters.received.Add(1) + s.mu.Unlock() + + defer s.blockWG.Done() + return s.enqueueBlock(ctx, evt) +} + +func (s *eventSubscription) enqueueNonBlocking(evt Event) deliveryResult { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.closed { + return deliveryResult{closed: true} + } + + s.counters.received.Add(1) + if s.opts.Backpressure == DropOldest { + return s.enqueueDropOldest(evt) + } + return s.enqueueDropNewest(evt) +} + +func (s *eventSubscription) enqueueDropNewest(evt Event) deliveryResult { + select { + case <-s.closing: + return deliveryResult{closed: true} + default: + } + + select { + case s.ch <- evt: + return deliveryResult{delivered: 1} + default: + s.counters.dropped.Add(1) + return deliveryResult{dropped: 1} + } +} + +func (s *eventSubscription) enqueueDropOldest(evt Event) deliveryResult { + select { + case <-s.closing: + return deliveryResult{closed: true} + default: + } + + select { + case s.ch <- evt: + return deliveryResult{delivered: 1} + default: + } + + dropped := 0 + select { + case <-s.ch: + s.counters.dropped.Add(1) + dropped = 1 + default: + } + + select { + case <-s.closing: + return deliveryResult{dropped: dropped, closed: true} + case s.ch <- evt: + return deliveryResult{delivered: 1, dropped: dropped} + default: + s.counters.dropped.Add(1) + return deliveryResult{dropped: dropped + 1} + } +} + +func (s *eventSubscription) enqueueBlock(ctx context.Context, evt Event) deliveryResult { + select { + case <-s.closing: + return deliveryResult{closed: true} + case s.ch <- evt: + return deliveryResult{delivered: 1} + case <-ctx.Done(): + s.counters.dropped.Add(1) + return deliveryResult{dropped: 1, blocked: 1} + } +} diff --git a/pkg/events/subscription_test.go b/pkg/events/subscription_test.go new file mode 100644 index 000000000..8fde731cc --- /dev/null +++ b/pkg/events/subscription_test.go @@ -0,0 +1,254 @@ +package events + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +func TestSubscribeOnceClosesAfterFirstEvent(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + var handled atomic.Uint64 + sub, err := bus.Channel().SubscribeOnce( + context.Background(), + SubscribeOptions{Name: "once", Buffer: 2}, + func(context.Context, Event) error { + handled.Add(1) + return nil + }, + ) + if err != nil { + t.Fatalf("SubscribeOnce failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: KindAgentTurnStart}) + waitForSubscriptionDone(t, sub) + bus.Publish(context.Background(), Event{Kind: KindAgentTurnEnd}) + + if got := handled.Load(); got != 1 { + t.Fatalf("handled = %d, want 1", got) + } + if got := sub.Stats().Handled; got != 1 { + t.Fatalf("subscription handled = %d, want 1", got) + } +} + +func TestUnsubscribeClosesChannel(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, ch, err := bus.Channel().SubscribeChan(context.Background(), SubscribeOptions{Name: "chan"}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + if err := sub.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + select { + case _, ok := <-ch: + if ok { + t.Fatal("channel is open, want closed") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for channel close") + } + waitForSubscriptionDone(t, sub) +} + +func TestBlockBackpressureCloseUnblocksPublisher(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, _, err := bus.Channel().SubscribeChan(context.Background(), SubscribeOptions{ + Name: "block-close", + Buffer: 1, + Backpressure: Block, + }) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + first := bus.Publish(context.Background(), Event{Kind: Kind("test.first")}) + if first.Delivered != 1 { + t.Fatalf("first Publish = %+v, want one delivered event", first) + } + + publishStarted := make(chan struct{}) + publishReturned := make(chan PublishResult, 1) + go func() { + close(publishStarted) + publishReturned <- bus.Publish(context.Background(), Event{Kind: Kind("test.second")}) + }() + + <-publishStarted + waitForStat(t, func() uint64 { + return sub.Stats().Received + }, 2) + select { + case result := <-publishReturned: + t.Fatalf("blocking Publish returned before close: %+v", result) + default: + } + + closeReturned := make(chan error, 1) + go func() { + closeReturned <- sub.Close() + }() + + select { + case err := <-closeReturned: + if err != nil { + t.Fatalf("Close failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for Close to unblock") + } + + select { + case <-publishReturned: + case <-time.After(time.Second): + t.Fatal("timed out waiting for blocking Publish to return after close") + } + waitForSubscriptionDone(t, sub) +} + +func TestHandlerPanicRecovered(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + sub, err := bus.Channel().Subscribe( + context.Background(), + SubscribeOptions{Name: "panic", Buffer: 1}, + func(context.Context, Event) error { + panic("boom") + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: KindAgentError}) + waitForStat(t, func() uint64 { + return sub.Stats().Panicked + }, 1) +} + +func TestLockedHandlerProcessesSequentially(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + var active atomic.Int64 + var maxActive atomic.Int64 + sub, err := bus.Channel().Subscribe( + context.Background(), + SubscribeOptions{Name: "locked", Buffer: 8, Concurrency: Locked}, + func(context.Context, Event) error { + current := active.Add(1) + for { + currentMax := maxActive.Load() + if current <= currentMax || maxActive.CompareAndSwap(currentMax, current) { + break + } + } + time.Sleep(10 * time.Millisecond) + active.Add(-1) + return nil + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + for i := 0; i < 5; i++ { + bus.Publish(context.Background(), Event{Kind: KindAgentLLMDelta}) + } + waitForStat(t, func() uint64 { + return sub.Stats().Handled + }, 5) + + if got := maxActive.Load(); got != 1 { + t.Fatalf("max active handlers = %d, want 1", got) + } +} + +func TestHandlerTimeoutDoesNotWedgeLockedSubscription(t *testing.T) { + t.Parallel() + + bus := NewBus() + defer closeBus(t, bus) + + releaseFirst := make(chan struct{}) + defer close(releaseFirst) + + var calls atomic.Uint64 + sub, err := bus.Channel().Subscribe( + context.Background(), + SubscribeOptions{Name: "timeout", Buffer: 2, Concurrency: Locked, Timeout: 20 * time.Millisecond}, + func(context.Context, Event) error { + if calls.Add(1) == 1 { + <-releaseFirst + } + return nil + }, + ) + if err != nil { + t.Fatalf("Subscribe failed: %v", err) + } + + bus.Publish(context.Background(), Event{Kind: Kind("test.first")}) + waitForStat(t, func() uint64 { + return sub.Stats().TimedOut + }, 1) + + bus.Publish(context.Background(), Event{Kind: Kind("test.second")}) + waitForStat(t, func() uint64 { + return sub.Stats().Handled + }, 1) + + if got := sub.Stats().Failed; got != 1 { + t.Fatalf("subscription failed = %d, want timeout failure", got) + } +} + +func waitForSubscriptionDone(t *testing.T, sub Subscription) { + t.Helper() + + select { + case <-sub.Done(): + case <-time.After(time.Second): + t.Fatal("timed out waiting for subscription to stop") + } +} + +func waitForStat(t *testing.T, stat func() uint64, want uint64) { + t.Helper() + + deadline := time.After(time.Second) + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + for { + if got := stat(); got >= want { + return + } + select { + case <-ticker.C: + case <-deadline: + t.Fatalf("timed out waiting for stat >= %d", want) + } + } +} diff --git a/pkg/events/types.go b/pkg/events/types.go new file mode 100644 index 000000000..2cfc0eaac --- /dev/null +++ b/pkg/events/types.go @@ -0,0 +1,77 @@ +package events + +import "time" + +// Kind identifies a runtime event category. +type Kind string + +// String returns the string representation of the event kind. +func (k Kind) String() string { + return string(k) +} + +// Event is the runtime event envelope shared across PicoClaw components. +type Event struct { + ID string `json:"id"` + Kind Kind `json:"kind"` + Time time.Time `json:"time"` + Source Source `json:"source"` + Scope Scope `json:"scope,omitempty"` + Correlation Correlation `json:"correlation,omitempty"` + Severity Severity `json:"severity,omitempty"` + Payload any `json:"payload,omitempty"` + Attrs map[string]any `json:"attrs,omitempty"` +} + +// Source identifies the component that emitted an event. +type Source struct { + Component string `json:"component"` + Name string `json:"name,omitempty"` +} + +// Scope identifies the runtime ownership of an event. +// +// Scope is intentionally limited to agent, session, turn, channel, chat, +// message, and sender identity. Tool, provider, model, and MCP details belong +// in Source, Payload, or Attrs. +type Scope struct { + RuntimeID string `json:"runtime_id,omitempty"` + + AgentID string `json:"agent_id,omitempty"` + SessionKey string `json:"session_key,omitempty"` + TurnID string `json:"turn_id,omitempty"` + + Channel string `json:"channel,omitempty"` + Account string `json:"account,omitempty"` + ChatID string `json:"chat_id,omitempty"` + TopicID string `json:"topic_id,omitempty"` + + SpaceID string `json:"space_id,omitempty"` + SpaceType string `json:"space_type,omitempty"` + ChatType string `json:"chat_type,omitempty"` + + SenderID string `json:"sender_id,omitempty"` + MessageID string `json:"message_id,omitempty"` +} + +// Correlation carries cross-event tracing fields. +type Correlation struct { + TraceID string `json:"trace_id,omitempty"` + ParentTurnID string `json:"parent_turn_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + ReplyToID string `json:"reply_to_id,omitempty"` +} + +// Severity describes the operational severity of an event. +type Severity string + +const ( + // SeverityDebug is used for verbose diagnostic events. + SeverityDebug Severity = "debug" + // SeverityInfo is used for normal lifecycle and activity events. + SeverityInfo Severity = "info" + // SeverityWarn is used for recoverable abnormal events. + SeverityWarn Severity = "warn" + // SeverityError is used for failed operations and unrecoverable events. + SeverityError Severity = "error" +) diff --git a/pkg/gateway/events.go b/pkg/gateway/events.go new file mode 100644 index 000000000..0f454ed7d --- /dev/null +++ b/pkg/gateway/events.go @@ -0,0 +1,53 @@ +package gateway + +import ( + "time" + + "github.com/sipeed/picoclaw/pkg/agent" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +type gatewayEventPayload struct { + DurationMS int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +func publishGatewayEvent( + al *agent.AgentLoop, + kind runtimeevents.Kind, + startedAt time.Time, + err error, +) { + if al == nil || al.RuntimeEventBus() == nil { + return + } + + severity := runtimeevents.SeverityInfo + payload := gatewayEventPayload{} + if !startedAt.IsZero() { + payload.DurationMS = time.Since(startedAt).Milliseconds() + } + if err != nil { + severity = runtimeevents.SeverityError + payload.Error = err.Error() + } + + al.RuntimeEventBus().PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "gateway"}, + Severity: severity, + Payload: payload, + Attrs: gatewayEventAttrs(payload), + }) +} + +func gatewayEventAttrs(payload gatewayEventPayload) map[string]any { + attrs := map[string]any{} + if payload.DurationMS > 0 { + attrs["duration_ms"] = payload.DurationMS + } + if payload.Error != "" { + attrs["error"] = payload.Error + } + return attrs +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index f58590d5b..4fd06d836 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -39,6 +39,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" @@ -114,6 +115,7 @@ func (p *startupBlockedProvider) GetDefaultModel() string { // Run starts the gateway runtime using the configuration loaded from configPath. func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runErr error) { + startedAt := time.Now() panicPath := filepath.Join(homePath, logPath, panicFile) panicFunc, err := logger.InitPanic(panicPath) if err != nil { @@ -197,6 +199,8 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr msgBus := bus.NewMessageBus() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + msgBus.SetEventPublisher(agentLoop.RuntimeEventBus()) + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayStart, startedAt, nil) fmt.Println("\n📦 Agent Status:") startupInfo := agentLoop.GetStartupInfo() @@ -216,6 +220,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr if err != nil { return err } + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReady, startedAt, nil) closeListeners = false // Setup manual reload channel for /reload endpoint @@ -262,7 +267,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr select { case <-sigChan: logger.Info("Shutting down...") - shutdownGateway(runningServices, agentLoop, provider, true) + shutdownGateway(runningServices, agentLoop, provider, msgBus, true) return nil case newCfg := <-configReloadChan: if !runningServices.reloading.CompareAndSwap(false, true) { @@ -312,10 +317,20 @@ func executeReload( msgBus *bus.MessageBus, allowEmptyStartup bool, debug bool, -) error { +) (err error) { + startedAt := time.Now() + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReloadStarted, startedAt, nil) defer runningServices.reloading.Store(false) + defer func() { + if err != nil { + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReloadFailed, startedAt, err) + return + } + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayReloadCompleted, startedAt, nil) + }() - return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) + err = handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) + return err } func createStartupProvider( @@ -383,7 +398,12 @@ func setupAndStartServices( fms.Start() } - runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) + runningServices.ChannelManager, err = channels.NewManager( + cfg, + msgBus, + runningServices.MediaStore, + channels.WithRuntimeEvents(agentLoop.RuntimeEventBus()), + ) if err != nil { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Stop() @@ -490,14 +510,21 @@ func shutdownGateway( runningServices *services, agentLoop *agent.AgentLoop, provider providers.LLMProvider, + msgBus *bus.MessageBus, fullShutdown bool, ) { + publishGatewayEvent(agentLoop, runtimeevents.KindGatewayShutdown, time.Time{}, nil) + if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown { cp.Close() } stopAndCleanupServices(runningServices, gracefulShutdownTimeout, false) + if fullShutdown && msgBus != nil { + msgBus.Close() + } + agentLoop.Stop() agentLoop.Close() diff --git a/pkg/gateway/gateway_test.go b/pkg/gateway/gateway_test.go index 60049337f..ab3833ba6 100644 --- a/pkg/gateway/gateway_test.go +++ b/pkg/gateway/gateway_test.go @@ -1,14 +1,20 @@ package gateway import ( + "context" + "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "testing" + "time" + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) func TestRun_StartupFailuresReturnErrorAndEmitStructuredLog(t *testing.T) { @@ -106,3 +112,100 @@ func TestGatewayRunStartupFailureHelper(t *testing.T) { fmt.Fprintln(os.Stdout, err.Error()) os.Exit(0) } + +func TestPublishGatewayEvent(t *testing.T) { + eventBus := runtimeevents.NewBus() + t.Cleanup(func() { + if err := eventBus.Close(); err != nil { + t.Fatalf("Close runtime event bus: %v", err) + } + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + sub, eventsCh, err := eventBus.Channel().OfKind(runtimeevents.KindGatewayStart).SubscribeChan( + ctx, + runtimeevents.SubscribeOptions{Name: "gateway-test", Buffer: 4}, + ) + if err != nil { + t.Fatalf("SubscribeChan() error = %v", err) + } + t.Cleanup(func() { + if err := sub.Close(); err != nil { + t.Fatalf("Close subscription: %v", err) + } + }) + + al := agent.NewAgentLoop( + config.DefaultConfig(), + bus.NewMessageBus(), + &startupBlockedProvider{reason: "not used"}, + agent.WithRuntimeEvents(eventBus), + ) + t.Cleanup(al.Close) + + startedAt := time.Now().Add(-1500 * time.Millisecond) + publishGatewayEvent(al, runtimeevents.KindGatewayStart, startedAt, nil) + + evt := receiveGatewayRuntimeEvent(t, eventsCh) + if evt.Kind != runtimeevents.KindGatewayStart || + evt.Source.Component != "gateway" || + evt.Severity != runtimeevents.SeverityInfo { + t.Fatalf("gateway event = %+v", evt) + } + payload, ok := evt.Payload.(gatewayEventPayload) + if !ok { + t.Fatalf("payload type = %T, want gatewayEventPayload", evt.Payload) + } + if payload.DurationMS <= 0 { + t.Fatalf("DurationMS = %d, want positive", payload.DurationMS) + } + if evt.Attrs["duration_ms"] == nil { + t.Fatalf("gateway event attrs missing duration_ms: %#v", evt.Attrs) + } +} + +func TestShutdownGatewayClosesMessageBus(t *testing.T) { + msgBus := bus.NewMessageBus() + al := agent.NewAgentLoop( + config.DefaultConfig(), + msgBus, + &startupBlockedProvider{reason: "not used"}, + ) + msgBus.SetEventPublisher(al.RuntimeEventBus()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sub, eventsCh, err := al.RuntimeEventBus().Channel().OfKind(runtimeevents.KindBusCloseCompleted).SubscribeChan( + ctx, + runtimeevents.SubscribeOptions{Name: "bus-close-test", Buffer: 4}, + ) + if err != nil { + t.Fatalf("SubscribeChan() error = %v", err) + } + defer func() { + _ = sub.Close() + }() + + shutdownGateway(&services{}, al, &startupBlockedProvider{reason: "not used"}, msgBus, true) + + evt := receiveGatewayRuntimeEvent(t, eventsCh) + if evt.Kind != runtimeevents.KindBusCloseCompleted { + t.Fatalf("shutdown event kind = %q, want %q", evt.Kind, runtimeevents.KindBusCloseCompleted) + } + if err := msgBus.PublishVoiceControl(context.Background(), bus.VoiceControl{}); !errors.Is(err, bus.ErrBusClosed) { + t.Fatalf("PublishVoiceControl after shutdown error = %v, want %v", err, bus.ErrBusClosed) + } +} + +func receiveGatewayRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt := <-ch: + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for gateway runtime event") + return runtimeevents.Event{} + } +} diff --git a/pkg/mcp/events.go b/pkg/mcp/events.go new file mode 100644 index 000000000..3b7f53f96 --- /dev/null +++ b/pkg/mcp/events.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" +) + +func (m *Manager) publishServerEvent( + kind runtimeevents.Kind, + serverName string, + cfg config.MCPServerConfig, + toolCount int, + err error, +) { + if m == nil || m.runtimeEvents == nil { + return + } + + severity := runtimeevents.SeverityInfo + if err != nil { + severity = runtimeevents.SeverityError + } + payload := ServerEventPayload{ + Server: serverName, + Type: mcpTransportType(cfg), + URL: cfg.URL, + Command: cfg.Command, + ToolCount: toolCount, + } + if err != nil { + payload.Error = err.Error() + } + + m.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "mcp", Name: serverName}, + Severity: severity, + Payload: payload, + Attrs: mcpServerEventAttrs(payload), + }) +} + +func (m *Manager) publishToolDiscovered(serverName string, cfg config.MCPServerConfig, toolName string) { + if m == nil || m.runtimeEvents == nil { + return + } + payload := ServerEventPayload{ + Server: serverName, + Type: mcpTransportType(cfg), + URL: cfg.URL, + Command: cfg.Command, + Tool: toolName, + } + m.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: runtimeevents.KindMCPToolDiscovered, + Source: runtimeevents.Source{Component: "mcp", Name: serverName}, + Severity: runtimeevents.SeverityInfo, + Payload: payload, + Attrs: mcpServerEventAttrs(payload), + }) +} + +func mcpServerEventAttrs(payload ServerEventPayload) map[string]any { + attrs := map[string]any{} + setMCPAttrString(attrs, "server", payload.Server) + setMCPAttrString(attrs, "type", payload.Type) + setMCPAttrString(attrs, "tool", payload.Tool) + if payload.ToolCount > 0 { + attrs["tool_count"] = payload.ToolCount + } + setMCPAttrString(attrs, "error", payload.Error) + return attrs +} + +func setMCPAttrString(attrs map[string]any, key, value string) { + if value != "" { + attrs[key] = value + } +} + +func mcpTransportType(cfg config.MCPServerConfig) string { + if cfg.Type != "" { + return cfg.Type + } + if cfg.URL != "" { + return "sse" + } + if cfg.Command != "" { + return "stdio" + } + return "" +} diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index 92ea426a6..958927767 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -16,6 +16,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -127,19 +128,47 @@ type ServerConnection struct { // Manager manages multiple MCP server connections type Manager struct { - servers map[string]*ServerConnection - mu sync.RWMutex - closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race - wg sync.WaitGroup // tracks in-flight CallTool calls + servers map[string]*ServerConnection + runtimeEvents runtimeevents.Bus + mu sync.RWMutex + closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race + wg sync.WaitGroup // tracks in-flight CallTool calls } var connectServerFunc = connectServer +// ManagerOption configures an MCP manager. +type ManagerOption func(*Manager) + +// WithRuntimeEvents injects the runtime event bus used for MCP observations. +func WithRuntimeEvents(eventBus runtimeevents.Bus) ManagerOption { + return func(m *Manager) { + m.runtimeEvents = eventBus + } +} + +// ServerEventPayload describes MCP server connection events. +type ServerEventPayload struct { + Server string `json:"server"` + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Command string `json:"command,omitempty"` + Tool string `json:"tool,omitempty"` + ToolCount int `json:"tool_count,omitempty"` + Error string `json:"error,omitempty"` +} + // NewManager creates a new MCP manager -func NewManager() *Manager { - return &Manager{ +func NewManager(opts ...ManagerOption) *Manager { + m := &Manager{ servers: make(map[string]*ServerConnection), } + for _, opt := range opts { + if opt != nil { + opt(m) + } + } + return m } // LoadFromConfig loads MCP servers from configuration @@ -264,8 +293,10 @@ func (m *Manager) ConnectServer( name string, cfg config.MCPServerConfig, ) error { + m.publishServerEvent(runtimeevents.KindMCPServerConnecting, name, cfg, 0, nil) conn, err := connectServerFunc(ctx, name, cfg) if err != nil { + m.publishServerEvent(runtimeevents.KindMCPServerFailed, name, cfg, 0, err) return err } @@ -274,10 +305,19 @@ func (m *Manager) ConnectServer( if m.closed.Load() { _ = conn.Session.Close() + m.publishServerEvent(runtimeevents.KindMCPServerFailed, name, cfg, 0, fmt.Errorf("manager is closed")) return fmt.Errorf("manager is closed") } m.servers[name] = conn + for _, tool := range conn.Tools { + toolName := "" + if tool != nil { + toolName = tool.Name + } + m.publishToolDiscovered(name, cfg, toolName) + } + m.publishServerEvent(runtimeevents.KindMCPServerConnected, name, cfg, len(conn.Tools), nil) return nil } diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index 682d4c346..5789a37a9 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -10,11 +10,13 @@ import ( "strings" "sync" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/jsonrpc" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" ) func TestLoadEnvFile(t *testing.T) { @@ -248,6 +250,95 @@ func TestNewManager_InitialState(t *testing.T) { } } +func TestConnectServerPublishesRuntimeEvents(t *testing.T) { + originalConnectServerFunc := connectServerFunc + t.Cleanup(func() { + connectServerFunc = originalConnectServerFunc + }) + + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindMCPServerConnected, + runtimeevents.KindMCPServerFailed, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-events", Buffer: 2}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + connectServerFunc = func( + _ context.Context, + name string, + cfg config.MCPServerConfig, + ) (*ServerConnection, error) { + if name == "bad" { + return nil, fmt.Errorf("connect failed") + } + return &ServerConnection{ + Name: name, + Config: cfg, + Tools: []*sdkmcp.Tool{{Name: "echo"}}, + }, nil + } + + mgr := NewManager(WithRuntimeEvents(eventBus)) + err = mgr.ConnectServer(context.Background(), "good", config.MCPServerConfig{ + Type: "stdio", + Command: "echo", + }) + if err != nil { + t.Fatalf("ConnectServer(good) error = %v", err) + } + connected := receiveMCPRuntimeEvent(t, eventsCh) + if connected.Kind != runtimeevents.KindMCPServerConnected || + connected.Source.Name != "good" || + connected.Severity != runtimeevents.SeverityInfo { + t.Fatalf("connected event = %+v", connected) + } + if connected.Attrs["server"] != "good" || + connected.Attrs["type"] != "stdio" || + connected.Attrs["tool_count"] != 1 { + t.Fatalf("connected attrs = %#v", connected.Attrs) + } + + err = mgr.ConnectServer(context.Background(), "bad", config.MCPServerConfig{ + Type: "stdio", + Command: "echo", + }) + if err == nil { + t.Fatal("expected ConnectServer(bad) to fail") + } + failed := receiveMCPRuntimeEvent(t, eventsCh) + if failed.Kind != runtimeevents.KindMCPServerFailed || + failed.Source.Name != "bad" || + failed.Severity != runtimeevents.SeverityError { + t.Fatalf("failed event = %+v", failed) + } + if failed.Attrs["server"] != "bad" || failed.Attrs["error"] != "connect failed" { + t.Fatalf("failed attrs = %#v", failed.Attrs) + } +} + +func receiveMCPRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } +} + func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) { mgr := NewManager() diff --git a/pkg/providers/common/google_schema.go b/pkg/providers/common/google_schema.go new file mode 100644 index 000000000..f7b2a337b --- /dev/null +++ b/pkg/providers/common/google_schema.go @@ -0,0 +1,642 @@ +package common + +import ( + "strconv" + "strings" +) + +const maxGeminiSchemaDepth = 64 + +var geminiSupportedTypes = map[string]bool{ + "array": true, + "boolean": true, + "integer": true, + "number": true, + "object": true, + "string": true, +} + +// SanitizeSchemaForGoogle reduces a JSON Schema to the conservative subset +// accepted by Google/Gemini-style function declarations. It resolves local +// refs, collapses composition keywords like anyOf/oneOf/allOf, and strips +// advanced keywords that Gemini-compatible backends often reject. +func SanitizeSchemaForGoogle(schema map[string]any) map[string]any { + if schema == nil { + return nil + } + + sanitizer := geminiSchemaSanitizer{root: schema} + result := sanitizer.sanitizeNode(schema, nil, 0) + if len(result) == 0 { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + } + if _, hasProps := result["properties"]; hasProps { + result["type"] = "object" + } + return result +} + +// SanitizeSchemaForGemini is kept as a compatibility alias for the original +// Google/Gemini sanitizer name. +func SanitizeSchemaForGemini(schema map[string]any) map[string]any { + return SanitizeSchemaForGoogle(schema) +} + +type geminiSchemaSanitizer struct { + root map[string]any +} + +func (s geminiSchemaSanitizer) sanitizeNode( + node map[string]any, + refTrail map[string]struct{}, + depth int, +) map[string]any { + if node == nil || depth > maxGeminiSchemaDepth { + return map[string]any{} + } + + normalized := s.normalizeNode(node, refTrail, depth) + if len(normalized) == 0 { + return map[string]any{} + } + + result := make(map[string]any) + + if desc, ok := normalized["description"].(string); ok && strings.TrimSpace(desc) != "" { + result["description"] = desc + } + + if schemaType := sanitizeGeminiSchemaType(normalized["type"]); schemaType != "" { + result["type"] = schemaType + } + + if enumValues := sanitizeGeminiEnum(normalized["enum"]); len(enumValues) > 0 { + result["enum"] = enumValues + } + + if propsRaw, ok := normalized["properties"].(map[string]any); ok { + props := make(map[string]any, len(propsRaw)) + for name, rawProp := range propsRaw { + propSchema, ok := rawProp.(map[string]any) + if !ok { + continue + } + sanitizedProp := s.sanitizeNode(propSchema, refTrail, depth+1) + if len(sanitizedProp) == 0 { + sanitizedProp = map[string]any{} + } + props[name] = sanitizedProp + } + result["properties"] = props + result["type"] = "object" + if required := sanitizeGeminiRequired(normalized["required"], props); len(required) > 0 { + result["required"] = required + } + } + + if itemsRaw, ok := normalized["items"].(map[string]any); ok { + items := s.sanitizeNode(itemsRaw, refTrail, depth+1) + if len(items) == 0 { + items = map[string]any{} + } + result["items"] = items + if _, hasType := result["type"]; !hasType { + result["type"] = "array" + } + } + + return result +} + +func (s geminiSchemaSanitizer) normalizeNode( + node map[string]any, + refTrail map[string]struct{}, + depth int, +) map[string]any { + if node == nil || depth > maxGeminiSchemaDepth { + return map[string]any{} + } + + normalized := cloneGeminiSchemaMap(node) + + if ref, ok := normalized["$ref"].(string); ok { + delete(normalized, "$ref") + if _, seen := refTrail[ref]; !seen { + if target, ok := s.resolveLocalSchemaRef(ref); ok { + nextTrail := cloneRefTrail(refTrail) + nextTrail[ref] = struct{}{} + normalized = mergeGeminiSchemaMaps( + s.normalizeNode(target, nextTrail, depth+1), + normalized, + ) + } + } + } + + if rawAllOf, ok := normalized["allOf"]; ok { + delete(normalized, "allOf") + for _, part := range schemaSlice(rawAllOf) { + normalized = mergeGeminiSchemaMaps( + normalized, + s.normalizeNode(part, refTrail, depth+1), + ) + } + } + + if rawAnyOf, ok := normalized["anyOf"]; ok { + delete(normalized, "anyOf") + normalized = mergeGeminiSchemaMaps( + s.mergeUnionBranches(schemaSlice(rawAnyOf), refTrail, depth+1), + normalized, + ) + } + + if rawOneOf, ok := normalized["oneOf"]; ok { + delete(normalized, "oneOf") + normalized = mergeGeminiSchemaMaps( + s.mergeUnionBranches(schemaSlice(rawOneOf), refTrail, depth+1), + normalized, + ) + } + + return normalized +} + +func (s geminiSchemaSanitizer) mergeUnionBranches( + branches []map[string]any, + refTrail map[string]struct{}, + depth int, +) map[string]any { + if len(branches) == 0 { + return map[string]any{} + } + + objectBranches := make([]map[string]any, 0, len(branches)) + arrayBranches := make([]map[string]any, 0, len(branches)) + nonNullBranches := make([]map[string]any, 0, len(branches)) + sameType := "" + sameTypeConsistent := true + + for _, branch := range branches { + normalized := s.normalizeNode(branch, refTrail, depth+1) + if len(normalized) == 0 { + continue + } + + branchType := geminiSchemaBranchType(normalized["type"]) + if branchType == "null" { + continue + } + nonNullBranches = append(nonNullBranches, normalized) + + if sameType == "" { + sameType = branchType + } else if branchType != "" && branchType != sameType { + sameTypeConsistent = false + } + + if branchType == "object" || hasSchemaProperties(normalized) { + objectBranches = append(objectBranches, normalized) + continue + } + if branchType == "array" || hasSchemaItems(normalized) { + arrayBranches = append(arrayBranches, normalized) + } + } + + if len(nonNullBranches) == 0 { + return map[string]any{} + } + if len(objectBranches) > 0 { + return mergeUnionObjectSchemas(objectBranches) + } + if len(arrayBranches) == len(nonNullBranches) && len(arrayBranches) > 0 { + return mergeUnionArraySchemas(arrayBranches) + } + if sameTypeConsistent && sameType != "" { + merged := map[string]any{} + for _, branch := range nonNullBranches { + merged = mergeGeminiSchemaMaps(merged, branch) + } + return merged + } + + best := nonNullBranches[0] + bestScore := geminiUnionBranchScore(best) + for _, branch := range nonNullBranches[1:] { + if score := geminiUnionBranchScore(branch); score > bestScore { + best = branch + bestScore = score + } + } + return cloneGeminiSchemaMap(best) +} + +func (s geminiSchemaSanitizer) resolveLocalSchemaRef(ref string) (map[string]any, bool) { + if ref == "#" { + return s.root, true + } + if !strings.HasPrefix(ref, "#/") { + return nil, false + } + + var current any = s.root + for _, rawToken := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { + token := strings.ReplaceAll(strings.ReplaceAll(rawToken, "~1", "/"), "~0", "~") + switch value := current.(type) { + case map[string]any: + next, ok := value[token] + if !ok { + return nil, false + } + current = next + case []any: + index, err := strconv.Atoi(token) + if err != nil || index < 0 || index >= len(value) { + return nil, false + } + current = value[index] + default: + return nil, false + } + } + + resolved, ok := current.(map[string]any) + return resolved, ok +} + +func mergeUnionObjectSchemas(branches []map[string]any) map[string]any { + merged := map[string]any{ + "type": "object", + "properties": map[string]any{}, + } + + var commonRequired map[string]struct{} + var requiredOrder []string + + for i, branch := range branches { + merged = mergeGeminiSchemaMaps(merged, branch) + + required := requiredStrings(branch["required"]) + if i == 0 { + commonRequired = make(map[string]struct{}, len(required)) + requiredOrder = append(requiredOrder, required...) + for _, name := range required { + commonRequired[name] = struct{}{} + } + continue + } + + current := make(map[string]struct{}, len(required)) + for _, name := range required { + current[name] = struct{}{} + } + for name := range commonRequired { + if _, ok := current[name]; !ok { + delete(commonRequired, name) + } + } + } + + if len(commonRequired) > 0 { + filtered := make([]string, 0, len(commonRequired)) + for _, name := range requiredOrder { + if _, ok := commonRequired[name]; ok { + filtered = append(filtered, name) + } + } + if len(filtered) > 0 { + merged["required"] = filtered + } + } else { + delete(merged, "required") + } + + return merged +} + +func mergeUnionArraySchemas(branches []map[string]any) map[string]any { + merged := map[string]any{ + "type": "array", + } + for _, branch := range branches { + merged = mergeGeminiSchemaMaps(merged, branch) + } + return merged +} + +func mergeGeminiSchemaMaps(base map[string]any, overlay map[string]any) map[string]any { + if len(base) == 0 { + return cloneGeminiSchemaMap(overlay) + } + if len(overlay) == 0 { + return cloneGeminiSchemaMap(base) + } + + result := cloneGeminiSchemaMap(base) + for key, value := range overlay { + switch key { + case "properties": + overlayProps, ok := value.(map[string]any) + if !ok { + continue + } + existing, _ := result["properties"].(map[string]any) + mergedProps := cloneGeminiSchemaMap(existing) + if mergedProps == nil { + mergedProps = make(map[string]any, len(overlayProps)) + } + for name, rawProp := range overlayProps { + propSchema, ok := rawProp.(map[string]any) + if !ok { + continue + } + if existingProp, ok := mergedProps[name].(map[string]any); ok { + mergedProps[name] = mergeGeminiSchemaMaps(existingProp, propSchema) + } else { + mergedProps[name] = cloneGeminiSchemaMap(propSchema) + } + } + result["properties"] = mergedProps + case "items": + overlayItems, ok := value.(map[string]any) + if !ok { + continue + } + if existingItems, ok := result["items"].(map[string]any); ok { + result["items"] = mergeGeminiSchemaMaps(existingItems, overlayItems) + } else { + result["items"] = cloneGeminiSchemaMap(overlayItems) + } + case "required": + if merged := mergeRequiredLists(result["required"], value); len(merged) > 0 { + result["required"] = merged + } + case "type": + if mergedType := mergeGeminiSchemaTypes(result["type"], value); mergedType != "" { + result["type"] = mergedType + } else { + delete(result, "type") + } + case "description": + desc, ok := value.(string) + if ok && strings.TrimSpace(desc) != "" { + result["description"] = desc + } + default: + result[key] = cloneGeminiSchemaValue(value) + } + } + + return result +} + +func mergeGeminiSchemaTypes(left any, right any) string { + leftType := geminiSchemaBranchType(left) + rightType := geminiSchemaBranchType(right) + + switch { + case leftType == "": + return rightType + case rightType == "": + return leftType + case leftType == rightType: + return leftType + case leftType == "null": + return rightType + case rightType == "null": + return leftType + default: + return "" + } +} + +func sanitizeGeminiSchemaType(raw any) string { + typeName := geminiSchemaBranchType(raw) + if typeName == "null" { + return "" + } + return typeName +} + +func geminiSchemaBranchType(raw any) string { + switch value := raw.(type) { + case string: + if value == "null" { + return value + } + if geminiSupportedTypes[value] { + return value + } + return "" + case []string: + return geminiSchemaBranchType(stringSliceToAny(value)) + case []any: + candidate := "" + sawNull := false + for _, item := range value { + typeName, ok := item.(string) + if !ok { + continue + } + if typeName == "null" { + sawNull = true + continue + } + if !geminiSupportedTypes[typeName] { + continue + } + if candidate == "" { + candidate = typeName + continue + } + if candidate != typeName { + return "" + } + } + if candidate == "" && sawNull { + return "null" + } + return candidate + default: + return "" + } +} + +func sanitizeGeminiEnum(raw any) []any { + values, ok := raw.([]any) + if !ok { + if stringValues, ok := raw.([]string); ok { + return stringSliceToAny(stringValues) + } + return nil + } + + sanitized := make([]any, 0, len(values)) + for _, value := range values { + switch value.(type) { + case string, bool, float64, int, int32, int64: + sanitized = append(sanitized, value) + } + } + if len(sanitized) == 0 { + return nil + } + return sanitized +} + +func sanitizeGeminiRequired(raw any, properties map[string]any) []string { + required := requiredStrings(raw) + if len(required) == 0 { + return nil + } + + filtered := make([]string, 0, len(required)) + seen := make(map[string]struct{}, len(required)) + for _, name := range required { + if _, ok := properties[name]; !ok { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + filtered = append(filtered, name) + } + if len(filtered) == 0 { + return nil + } + return filtered +} + +func requiredStrings(raw any) []string { + switch value := raw.(type) { + case []string: + return append([]string(nil), value...) + case []any: + required := make([]string, 0, len(value)) + for _, item := range value { + name, ok := item.(string) + if ok { + required = append(required, name) + } + } + return required + default: + return nil + } +} + +func mergeRequiredLists(left any, right any) []string { + merged := make([]string, 0) + seen := map[string]struct{}{} + + for _, name := range append(requiredStrings(left), requiredStrings(right)...) { + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + merged = append(merged, name) + } + + return merged +} + +func geminiUnionBranchScore(schema map[string]any) int { + score := 0 + if hasSchemaProperties(schema) { + score += 20 + } + if hasSchemaItems(schema) { + score += 10 + } + if _, ok := schema["enum"]; ok { + score += 5 + } + if _, ok := schema["description"]; ok { + score += 2 + } + score += len(schema) + return score +} + +func hasSchemaProperties(schema map[string]any) bool { + props, ok := schema["properties"].(map[string]any) + return ok && len(props) > 0 +} + +func hasSchemaItems(schema map[string]any) bool { + _, ok := schema["items"].(map[string]any) + return ok +} + +func schemaSlice(raw any) []map[string]any { + switch value := raw.(type) { + case []map[string]any: + return append([]map[string]any(nil), value...) + case []any: + schemas := make([]map[string]any, 0, len(value)) + for _, item := range value { + schema, ok := item.(map[string]any) + if ok { + schemas = append(schemas, schema) + } + } + return schemas + default: + return nil + } +} + +func cloneGeminiSchemaMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for key, value := range in { + out[key] = cloneGeminiSchemaValue(value) + } + return out +} + +func cloneGeminiSchemaValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneGeminiSchemaMap(typed) + case []any: + out := make([]any, len(typed)) + for i, item := range typed { + out[i] = cloneGeminiSchemaValue(item) + } + return out + case []string: + return append([]string(nil), typed...) + default: + return typed + } +} + +func cloneRefTrail(in map[string]struct{}) map[string]struct{} { + if len(in) == 0 { + return make(map[string]struct{}) + } + out := make(map[string]struct{}, len(in)) + for key := range in { + out[key] = struct{}{} + } + return out +} + +func stringSliceToAny(values []string) []any { + if len(values) == 0 { + return nil + } + result := make([]any, len(values)) + for i, value := range values { + result[i] = value + } + return result +} diff --git a/pkg/providers/common/google_schema_test.go b/pkg/providers/common/google_schema_test.go new file mode 100644 index 000000000..23aadbf98 --- /dev/null +++ b/pkg/providers/common/google_schema_test.go @@ -0,0 +1,254 @@ +package common + +import "testing" + +func TestSanitizeSchemaForGemini_DereferencesRefsAndFlattensUnions(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + "icon": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/emoji"}, + map[string]any{"type": "null"}, + }, + }, + "data": map[string]any{ + "$ref": "#/$defs/dataPayload", + }, + }, + "required": []any{"parent", "icon", "missing"}, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{ + "type": "string", + }, + }, + "required": []any{"page_id"}, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{ + "type": "string", + }, + }, + "required": []any{"database_id"}, + }, + "emoji": map[string]any{ + "type": "string", + "pattern": "^:[a-z_]+:$", + }, + "dataPayload": map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "minLength": 1, + }, + "count": map[string]any{ + "type": "integer", + "minimum": 1, + }, + }, + "required": []any{"name"}, + }, + }, + } + + got := SanitizeSchemaForGemini(schema) + assertSchemaKeyAbsent(t, got, "$defs") + assertSchemaKeyAbsent(t, got, "$ref") + assertSchemaKeyAbsent(t, got, "anyOf") + assertSchemaKeyAbsent(t, got, "oneOf") + assertSchemaKeyAbsent(t, got, "allOf") + assertSchemaKeyAbsent(t, got, "additionalProperties") + assertSchemaKeyAbsent(t, got, "pattern") + assertSchemaKeyAbsent(t, got, "minLength") + assertSchemaKeyAbsent(t, got, "minimum") + + if got["type"] != "object" { + t.Fatalf("top-level type = %#v, want object", got["type"]) + } + + props, ok := got["properties"].(map[string]any) + if !ok { + t.Fatalf("properties = %#v, want map", got["properties"]) + } + + parent, ok := props["parent"].(map[string]any) + if !ok { + t.Fatalf("parent schema = %#v, want map", props["parent"]) + } + if parent["type"] != "object" { + t.Fatalf("parent.type = %#v, want object", parent["type"]) + } + parentProps, ok := parent["properties"].(map[string]any) + if !ok { + t.Fatalf("parent.properties = %#v, want map", parent["properties"]) + } + if _, found := parentProps["page_id"]; !found { + t.Fatalf("parent.properties missing page_id: %#v", parentProps) + } + if _, found := parentProps["database_id"]; !found { + t.Fatalf("parent.properties missing database_id: %#v", parentProps) + } + if _, hasRequired := parent["required"]; hasRequired { + t.Fatalf("parent.required = %#v, want omitted for merged anyOf branches", parent["required"]) + } + + icon, ok := props["icon"].(map[string]any) + if !ok { + t.Fatalf("icon schema = %#v, want map", props["icon"]) + } + if icon["type"] != "string" { + t.Fatalf("icon.type = %#v, want string", icon["type"]) + } + + data, ok := props["data"].(map[string]any) + if !ok { + t.Fatalf("data schema = %#v, want map", props["data"]) + } + if data["type"] != "object" { + t.Fatalf("data.type = %#v, want object", data["type"]) + } + dataProps, ok := data["properties"].(map[string]any) + if !ok { + t.Fatalf("data.properties = %#v, want map", data["properties"]) + } + if _, found := dataProps["name"]; !found { + t.Fatalf("data.properties missing name: %#v", dataProps) + } + if _, found := dataProps["count"]; !found { + t.Fatalf("data.properties missing count: %#v", dataProps) + } + + required, ok := got["required"].([]string) + if !ok { + t.Fatalf("required = %#v, want []string", got["required"]) + } + if len(required) != 2 || required[0] != "parent" || required[1] != "icon" { + t.Fatalf("required = %#v, want [parent icon]", required) + } +} + +func TestSanitizeSchemaForGemini_MergesAllOfAndFiltersRequired(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "payload": map[string]any{ + "allOf": []any{ + map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + }, + }, + "required": []any{"id"}, + }, + map[string]any{ + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + }, + "count": map[string]any{ + "type": "integer", + "minimum": 1, + }, + }, + "required": []any{"name", "missing"}, + }, + }, + }, + }, + } + + got := SanitizeSchemaForGemini(schema) + props := got["properties"].(map[string]any) + payload := props["payload"].(map[string]any) + + if payload["type"] != "object" { + t.Fatalf("payload.type = %#v, want object", payload["type"]) + } + payloadProps, ok := payload["properties"].(map[string]any) + if !ok { + t.Fatalf("payload.properties = %#v, want map", payload["properties"]) + } + for _, key := range []string{"id", "name", "count"} { + if _, found := payloadProps[key]; !found { + t.Fatalf("payload.properties missing %q: %#v", key, payloadProps) + } + } + + required, ok := payload["required"].([]string) + if !ok { + t.Fatalf("payload.required = %#v, want []string", payload["required"]) + } + if len(required) != 2 || required[0] != "id" || required[1] != "name" { + t.Fatalf("payload.required = %#v, want [id name]", required) + } + + assertSchemaKeyAbsent(t, payload, "allOf") + assertSchemaKeyAbsent(t, payload, "minimum") +} + +func TestSanitizeSchemaForGemini_HandlesRecursiveRefs(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "tree": map[string]any{ + "$ref": "#/$defs/node", + }, + }, + "$defs": map[string]any{ + "node": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + }, + "child": map[string]any{ + "$ref": "#/$defs/node", + }, + }, + }, + }, + } + + got := SanitizeSchemaForGemini(schema) + props := got["properties"].(map[string]any) + tree := props["tree"].(map[string]any) + if tree["type"] != "object" { + t.Fatalf("tree.type = %#v, want object", tree["type"]) + } + assertSchemaKeyAbsent(t, tree, "$ref") +} + +func assertSchemaKeyAbsent(t *testing.T, value any, key string) { + t.Helper() + + switch typed := value.(type) { + case map[string]any: + if _, found := typed[key]; found { + t.Fatalf("schema still contains key %q: %#v", key, typed) + } + for _, nested := range typed { + assertSchemaKeyAbsent(t, nested, key) + } + case []any: + for _, nested := range typed { + assertSchemaKeyAbsent(t, nested, key) + } + case []string: + return + } +} diff --git a/pkg/providers/common/tool_schema_transform.go b/pkg/providers/common/tool_schema_transform.go new file mode 100644 index 000000000..10e96d056 --- /dev/null +++ b/pkg/providers/common/tool_schema_transform.go @@ -0,0 +1,59 @@ +package common + +import ( + "fmt" + "strings" +) + +const ( + ToolSchemaTransformOff = "" + ToolSchemaTransformSimple = "simple" +) + +// NormalizeToolSchemaTransform resolves user-facing aliases to a canonical +// transform mode. Empty values and explicit "off"-style values disable schema +// transformation. +func NormalizeToolSchemaTransform(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "off", "none", "native": + return ToolSchemaTransformOff, nil + case "simple", "basic", "strict", "flat": + return ToolSchemaTransformSimple, nil + default: + return "", fmt.Errorf("unsupported tool_schema_transform %q (supported: off, simple)", raw) + } +} + +// TransformToolDefinitions clones tool definitions and applies the configured +// schema transform to function parameter schemas. When the transform is off, the +// original slice is returned unchanged. +func TransformToolDefinitions(tools []ToolDefinition, transform string) ([]ToolDefinition, error) { + transform, err := NormalizeToolSchemaTransform(transform) + if err != nil { + return nil, err + } + if transform == ToolSchemaTransformOff || len(tools) == 0 { + return tools, nil + } + + out := make([]ToolDefinition, len(tools)) + for i, tool := range tools { + out[i] = tool + if tool.Type != "function" { + continue + } + out[i].Function = tool.Function + out[i].Function.Parameters = transformToolSchema(tool.Function.Parameters, transform) + } + + return out, nil +} + +func transformToolSchema(schema map[string]any, transform string) map[string]any { + switch transform { + case ToolSchemaTransformSimple: + return SanitizeSchemaForGoogle(schema) + default: + return cloneGeminiSchemaMap(schema) + } +} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index ce83c6c54..aa99d6d38 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -110,19 +110,7 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { if provider := strings.TrimSpace(cfg.Provider); provider != "" { return NormalizeProvider(provider), model } - if model == "" { - return "", "" - } - - protocol, rest, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - protocol = strings.TrimSpace(protocol) - if protocol == "" { - return "", strings.TrimSpace(rest) - } - return NormalizeProvider(protocol), strings.TrimSpace(rest) + return SplitModelProviderAndID(model, "openai") } // ResolveAPIBase returns the configured API base, or the protocol default when @@ -154,6 +142,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } protocol, modelID := ExtractProtocol(cfg) + authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod)) userAgent := cfg.UserAgent if userAgent == "" { @@ -163,12 +152,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { provider, err := createCodexAuthProvider() if err != nil { return nil, "", err } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) } // OpenAI with API key if cfg.APIKey() == "" && cfg.APIBase == "" { @@ -189,7 +178,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.CustomHeaders, ) provider.SetProviderName(protocol) - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) case "azure", "azure-openai": // Azure OpenAI uses deployment-based URLs, api-key header auth, @@ -202,13 +191,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err "api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)", ) } - return azure.NewProviderWithTimeout( + return finalizeProviderFromConfig(azure.NewProviderWithTimeout( cfg.APIKey(), cfg.APIBase, cfg.Proxy, userAgent, cfg.RequestTimeout, - ), modelID, nil + ), modelID, cfg) case "bedrock": // AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.) @@ -244,7 +233,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if err != nil { return nil, "", fmt.Errorf("creating bedrock provider: %w", err) } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", @@ -270,7 +259,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.CustomHeaders, ) provider.SetProviderName(protocol) - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) case "gemini": if cfg.APIKey() == "" && cfg.APIBase == "" { @@ -280,7 +269,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewGeminiProvider( + return finalizeProviderFromConfig(NewGeminiProvider( cfg.APIKey(), apiBase, cfg.Proxy, @@ -288,7 +277,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, cfg.ExtraBody, cfg.CustomHeaders, - ), modelID, nil + ), modelID, cfg) case "minimax": // Minimax requires reasoning_split: true in the request body @@ -317,16 +306,16 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.CustomHeaders, ) provider.SetProviderName(protocol) - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) case "anthropic": - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { return nil, "", err } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) } // Use API key with HTTP API apiBase := cfg.APIBase @@ -347,7 +336,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.CustomHeaders, ) provider.SetProviderName(protocol) - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) case "anthropic-messages": // Anthropic Messages API with native format (HTTP-based, no SDK) @@ -358,12 +347,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model) } - return anthropicmessages.NewProviderWithTimeout( + return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, userAgent, cfg.RequestTimeout, - ), modelID, nil + ), modelID, cfg) case "coding-plan-anthropic", "alibaba-coding-anthropic": // Alibaba Coding Plan with Anthropic-compatible API @@ -374,29 +363,29 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) } - return anthropicmessages.NewProviderWithTimeout( + return finalizeProviderFromConfig(anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, userAgent, cfg.RequestTimeout, - ), modelID, nil + ), modelID, cfg) case "antigravity": - return NewAntigravityProvider(), modelID, nil + return finalizeProviderFromConfig(NewAntigravityProvider(), modelID, cfg) case "claude-cli", "claudecli": workspace := cfg.Workspace if workspace == "" { workspace = "." } - return NewClaudeCliProvider(workspace), modelID, nil + return finalizeProviderFromConfig(NewClaudeCliProvider(workspace), modelID, cfg) case "codex-cli", "codexcli": workspace := cfg.Workspace if workspace == "" { workspace = "." } - return NewCodexCliProvider(workspace), modelID, nil + return finalizeProviderFromConfig(NewCodexCliProvider(workspace), modelID, cfg) case "github-copilot", "copilot": apiBase := cfg.APIBase @@ -411,15 +400,27 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if err != nil { return nil, "", err } - return provider, modelID, nil + return finalizeProviderFromConfig(provider, modelID, cfg) default: return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) } } +func finalizeProviderFromConfig( + provider LLMProvider, + modelID string, + cfg *config.ModelConfig, +) (LLMProvider, string, error) { + wrapped, err := wrapProviderWithToolSchemaTransform(provider, cfg.ToolSchemaTransform) + if err != nil { + return nil, "", err + } + return wrapped, modelID, nil +} + func isEmptyAPIKeyAllowed(protocol string) bool { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) return ok && meta.emptyAPIKeyAllowed } @@ -439,9 +440,19 @@ func DefaultAPIBaseForProtocol(protocol string) string { // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) if !ok { return "" } return meta.defaultAPIBase } + +func protocolMetaForName(protocol string) (protocolMeta, bool) { + if meta, ok := protocolMetaByName[protocol]; ok { + return meta, true + } + if meta, ok := attachedModelProviderMetaByName[protocol]; ok { + return meta.protocolMeta, true + } + return protocolMeta{}, false +} diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 3dd1eefb3..eb9b3d600 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -101,6 +102,12 @@ func TestExtractProtocol(t *testing.T) { wantProtocol: "", wantModelID: "gpt-4o", }, + { + name: "unknown prefix falls back to openai", + config: &config.ModelConfig{Model: "meta-llama/Llama-3.1-8B-Instruct"}, + wantProtocol: "openai", + wantModelID: "meta-llama/Llama-3.1-8B-Instruct", + }, { name: "nil config", wantProtocol: "", @@ -605,6 +612,41 @@ func TestCreateProviderFromConfig_CodexCLI(t *testing.T) { } } +func TestCreateProviderFromConfig_OpenAIMixedCaseAuthMethodUsesOAuthBranch(t *testing.T) { + origGetCredential := getCredential + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want %q", provider, "openai") + } + return &auth.AuthCredential{ + AccessToken: "test-token", + AccountID: "acct-test", + Provider: "openai", + AuthMethod: "oauth", + }, nil + } + t.Cleanup(func() { + getCredential = origGetCredential + }) + + cfg := &config.ModelConfig{ + ModelName: "test-openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "OAuth", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gpt-5.4" { + t.Errorf("modelID = %q, want %q", modelID, "gpt-5.4") + } +} + func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-no-key", @@ -619,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ - ModelName: "test-unknown", - Model: "unknown-protocol/model", + ModelName: "test-unknown-provider", + Provider: "unknown-protocol", + Model: "model", } cfg.SetAPIKey("test-key") @@ -630,6 +673,26 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { } } +func TestCreateProviderFromConfig_UnknownModelPrefixDefaultsToOpenAI(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-unknown-model-prefix", + Model: "meta-llama/Llama-3.1-8B-Instruct", + APIBase: "https://api.example.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("modelID = %q, want full model ID", modelID) + } +} + func TestCreateProviderFromConfig_NilConfig(t *testing.T) { _, _, err := CreateProviderFromConfig(nil) if err == nil { @@ -889,6 +952,71 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } +func TestModelProviderOptions(t *testing.T) { + options := ModelProviderOptions() + if len(options) == 0 { + t.Fatal("ModelProviderOptions() returned no options") + } + + seen := make(map[string]ModelProviderOption, len(options)) + for _, option := range options { + seen[option.ID] = option + } + + if _, ok := seen["openai"]; !ok { + t.Fatal("openai option missing") + } + if option, ok := seen["openai"]; ok && !option.CreateAllowed { + t.Fatal("openai should be creatable") + } + if option, ok := seen["lmstudio"]; !ok { + t.Fatal("lmstudio option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty API keys") + } + if option, ok := seen["anthropic"]; !ok { + t.Fatal("anthropic option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := seen["azure"]; !ok { + t.Fatal("azure option missing") + } + if option, ok := seen["bedrock"]; !ok { + t.Fatal("bedrock option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should be creatable and defer credential/build errors to runtime") + } + if option, ok := seen["elevenlabs"]; !ok { + t.Fatal("elevenlabs option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be ASR-only and therefore not allowed as a default chat model") + } + } + if option, ok := seen["antigravity"]; !ok { + t.Fatal("antigravity option missing") + } else { + if !option.CreateAllowed { + t.Fatal("antigravity should be creatable") + } + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + if option, ok := seen["github-copilot"]; !ok { + t.Fatal("github-copilot option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } +} + func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { var requestBody map[string]any @@ -1202,3 +1330,42 @@ func TestCreateProviderFromConfig_BedrockWithEndpointURL(t *testing.T) { // Unexpected error - fail the test t.Errorf("unexpected error from bedrock provider: %v", err) } + +func TestCreateProviderFromConfig_ToolSchemaTransformWrapsProvider(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "claude-cli-test", + Provider: "claude-cli", + Model: "claude-sonnet-4.6", + Workspace: t.TempDir(), + ToolSchemaTransform: "simple", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if modelID != "claude-sonnet-4.6" { + t.Fatalf("modelID = %q, want %q", modelID, "claude-sonnet-4.6") + } + if _, ok := provider.(*toolSchemaTransformProvider); !ok { + t.Fatalf("provider = %T, want *toolSchemaTransformProvider", provider) + } +} + +func TestCreateProviderFromConfig_InvalidToolSchemaTransform(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "claude-cli-test", + Provider: "claude-cli", + Model: "claude-sonnet-4.6", + Workspace: t.TempDir(), + ToolSchemaTransform: "invalid", + } + + _, _, err := CreateProviderFromConfig(cfg) + if err == nil { + t.Fatal("CreateProviderFromConfig() expected error for invalid tool_schema_transform") + } + if !strings.Contains(err.Error(), "tool_schema_transform") { + t.Fatalf("error = %v, want mention tool_schema_transform", err) + } +} diff --git a/pkg/providers/httpapi/gemini_helpers.go b/pkg/providers/httpapi/gemini_helpers.go index a2b2d63c3..87cc4c084 100644 --- a/pkg/providers/httpapi/gemini_helpers.go +++ b/pkg/providers/httpapi/gemini_helpers.go @@ -12,66 +12,6 @@ func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake return "" } -var geminiUnsupportedKeywords = map[string]bool{ - "patternProperties": true, - "additionalProperties": true, - "$schema": true, - "$id": true, - "$ref": true, - "$defs": true, - "definitions": true, - "examples": true, - "minLength": true, - "maxLength": true, - "minimum": true, - "maximum": true, - "multipleOf": true, - "pattern": true, - "format": true, - "minItems": true, - "maxItems": true, - "uniqueItems": true, - "minProperties": true, - "maxProperties": true, -} - -func sanitizeSchemaForGemini(schema map[string]any) map[string]any { - if schema == nil { - return nil - } - - result := make(map[string]any) - for k, v := range schema { - if geminiUnsupportedKeywords[k] { - continue - } - switch val := v.(type) { - case map[string]any: - result[k] = sanitizeSchemaForGemini(val) - case []any: - sanitized := make([]any, len(val)) - for i, item := range val { - if m, ok := item.(map[string]any); ok { - sanitized[i] = sanitizeSchemaForGemini(m) - } else { - sanitized[i] = item - } - } - result[k] = sanitized - default: - result[k] = v - } - } - - if _, hasProps := result["properties"]; hasProps { - if _, hasType := result["type"]; !hasType { - result["type"] = "object" - } - } - - return result -} - func extractProtocol(model string) (protocol, modelID string) { model = strings.TrimSpace(model) protocol, modelID, found := strings.Cut(model, "/") diff --git a/pkg/providers/httpapi/gemini_provider.go b/pkg/providers/httpapi/gemini_provider.go index d1d523757..395c555d1 100644 --- a/pkg/providers/httpapi/gemini_provider.go +++ b/pkg/providers/httpapi/gemini_provider.go @@ -264,7 +264,7 @@ func (p *GeminiProvider) buildRequestBody( funcDecls = append(funcDecls, geminiFunctionDeclaration{ Name: t.Function.Name, Description: t.Function.Description, - Parameters: sanitizeSchemaForGemini(t.Function.Parameters), + Parameters: t.Function.Parameters, }) } if len(funcDecls) > 0 { diff --git a/pkg/providers/httpapi/gemini_provider_test.go b/pkg/providers/httpapi/gemini_provider_test.go index aade90358..b455357c0 100644 --- a/pkg/providers/httpapi/gemini_provider_test.go +++ b/pkg/providers/httpapi/gemini_provider_test.go @@ -259,6 +259,64 @@ func TestGeminiProvider_ChatStreamSkipsEmptyDataFrames(t *testing.T) { } } +func TestGeminiProvider_BuildRequestBody_PreservesComplexToolSchemasByDefault(t *testing.T) { + provider := NewGeminiProvider("test-key", "https://example.com/v1beta", "", "", 0, nil, nil) + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + }, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{"type": "string"}, + }, + "required": []any{"page_id"}, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{"type": "string"}, + }, + "required": []any{"database_id"}, + }, + }, + } + + body := provider.buildRequestBody( + []Message{{Role: "user", Content: "hello"}}, + []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "mcp_notion_create", + Description: "Create a Notion object", + Parameters: schema, + }, + }}, + "gemini-3-flash-preview", + nil, + ) + + tools, ok := body["tools"].([]geminiTool) + if !ok || len(tools) != 1 { + t.Fatalf("tools = %#v, want one geminiTool", body["tools"]) + } + got, ok := tools[0].FunctionDeclarations[0].Parameters.(map[string]any) + if !ok { + t.Fatalf("parameters = %#v, want map", tools[0].FunctionDeclarations[0].Parameters) + } + + if got["$defs"] == nil { + t.Fatalf("parameters = %#v, want raw schema with $defs preserved by default", got) + } +} + func TestGeminiProvider_ChatStreamReturnsErrorOnInvalidDataFrame(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index be9f63bc6..48e3fb4cb 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef { 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} + provider, model := SplitModelProviderAndID(raw, defaultProvider) + if model == "" { + return nil } - return &ModelRef{ - Provider: NormalizeProvider(defaultProvider), - Model: raw, + Provider: provider, + Model: model, } } @@ -53,6 +48,8 @@ func NormalizeProvider(provider string) string { return "zhipu" case "google": return "gemini" + case "google-antigravity": + return "antigravity" case "alibaba-coding", "qwen-coding": return "coding-plan" case "alibaba-coding-anthropic": @@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string { return "qwen-intl" case "dashscope-us": return "qwen-us" + case "azure-openai": + return "azure" + case "claudecli": + return "claude-cli" + case "codexcli": + return "codex-cli" + case "copilot": + return "github-copilot" } return p diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 040c511ba..9a164bf48 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -72,7 +72,12 @@ func TestNormalizeProvider(t *testing.T) { {"claude", "anthropic"}, {"glm", "zhipu"}, {"google", "gemini"}, + {"google-antigravity", "antigravity"}, {"groq", "groq"}, + {"azure-openai", "azure"}, + {"claudecli", "claude-cli"}, + {"codexcli", "codex-cli"}, + {"copilot", "github-copilot"}, // Alibaba Coding Plan aliases {"alibaba-coding", "coding-plan"}, {"qwen-coding", "coding-plan"}, @@ -131,3 +136,42 @@ func TestParseModelRef_DefaultProviderNormalization(t *testing.T) { t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider) } } + +func TestParseModelRef_UnknownPrefixFallsBackToDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "openai" { + t.Fatalf("provider = %q, want openai", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_UnknownPrefixPreservesEmptyDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "" { + t.Fatalf("provider = %q, want empty", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_KnownNonSelectableProvider(t *testing.T) { + ref := ParseModelRef("bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "bedrock" { + t.Fatalf("provider = %q, want bedrock", ref.Provider) + } + if ref.Model != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want preserved bedrock model ID", ref.Model) + } +} diff --git a/pkg/providers/oauth/antigravity_provider.go b/pkg/providers/oauth/antigravity_provider.go index 1ac2d9c7f..abf1e4bd6 100644 --- a/pkg/providers/oauth/antigravity_provider.go +++ b/pkg/providers/oauth/antigravity_provider.go @@ -291,18 +291,17 @@ func (p *AntigravityProvider) buildRequest( } } - // Build tools (sanitize schemas for Gemini compatibility) + // Build tools if len(tools) > 0 { var funcDecls []antigravityFuncDecl for _, t := range tools { if t.Type != "function" { continue } - params := sanitizeSchemaForGemini(t.Function.Parameters) funcDecls = append(funcDecls, antigravityFuncDecl{ Name: t.Function.Name, Description: t.Function.Description, - Parameters: params, + Parameters: t.Function.Parameters, }) } if len(funcDecls) > 0 { @@ -446,71 +445,6 @@ func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake return "" } -// --- Schema sanitization --- - -// Google/Gemini doesn't support many JSON Schema keywords that other providers accept. -var geminiUnsupportedKeywords = map[string]bool{ - "patternProperties": true, - "additionalProperties": true, - "$schema": true, - "$id": true, - "$ref": true, - "$defs": true, - "definitions": true, - "examples": true, - "minLength": true, - "maxLength": true, - "minimum": true, - "maximum": true, - "multipleOf": true, - "pattern": true, - "format": true, - "minItems": true, - "maxItems": true, - "uniqueItems": true, - "minProperties": true, - "maxProperties": true, -} - -func sanitizeSchemaForGemini(schema map[string]any) map[string]any { - if schema == nil { - return nil - } - - result := make(map[string]any) - for k, v := range schema { - if geminiUnsupportedKeywords[k] { - continue - } - // Recursively sanitize nested objects - switch val := v.(type) { - case map[string]any: - result[k] = sanitizeSchemaForGemini(val) - case []any: - sanitized := make([]any, len(val)) - for i, item := range val { - if m, ok := item.(map[string]any); ok { - sanitized[i] = sanitizeSchemaForGemini(m) - } else { - sanitized[i] = item - } - } - result[k] = sanitized - default: - result[k] = v - } - } - - // Ensure top-level has type: "object" if properties are present - if _, hasProps := result["properties"]; hasProps { - if _, hasType := result["type"]; !hasType { - result["type"] = "object" - } - } - - return result -} - // --- Token source --- func createAntigravityTokenSource() func() (string, string, error) { diff --git a/pkg/providers/oauth/antigravity_provider_test.go b/pkg/providers/oauth/antigravity_provider_test.go index 2989f8519..d85e47dfa 100644 --- a/pkg/providers/oauth/antigravity_provider_test.go +++ b/pkg/providers/oauth/antigravity_provider_test.go @@ -1,6 +1,8 @@ package oauthprovider -import "testing" +import ( + "testing" +) func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { p := &AntigravityProvider{} @@ -71,3 +73,70 @@ func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) { t.Fatalf("Usage.TotalTokens = %v, want %d", resp.Usage, 216) } } + +func TestBuildRequest_PreservesComplexToolSchemasByDefault(t *testing.T) { + p := &AntigravityProvider{} + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + "icon": map[string]any{ + "anyOf": []any{ + map[string]any{"type": "null"}, + map[string]any{"$ref": "#/$defs/emoji"}, + }, + }, + }, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{"type": "string"}, + }, + "required": []any{"page_id"}, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{"type": "string"}, + }, + "required": []any{"database_id"}, + }, + "emoji": map[string]any{ + "type": "string", + "pattern": "^:[a-z_]+:$", + }, + }, + } + + req := p.buildRequest( + []Message{{Role: "user", Content: "hello"}}, + []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "mcp_notion_create", + Description: "Create a Notion object", + Parameters: schema, + }, + }}, + "gemini-3-flash", + nil, + ) + + if len(req.Tools) != 1 || len(req.Tools[0].FunctionDeclarations) != 1 { + t.Fatalf("request tools = %#v, want one function declaration", req.Tools) + } + + got, ok := req.Tools[0].FunctionDeclarations[0].Parameters.(map[string]any) + if !ok { + t.Fatalf("parameters = %#v, want map", req.Tools[0].FunctionDeclarations[0].Parameters) + } + if got["$defs"] == nil { + t.Fatalf("parameters = %#v, want raw schema with $defs preserved by default", got) + } +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index c3733ce3a..be3e77a43 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -270,6 +270,10 @@ func filterDeepSeekReasoningTurn(messages []Message) []Message { } cloned := msg + // DeepSeek thinking-mode replay only requires reasoning_content for + // turns that participate in a tool interaction round. For plain + // assistant turns between two user messages, the docs say the API will + // ignore reasoning_content on replay, so we strip it here. if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction { cloned.ReasoningContent = "" } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 594048ea5..4f68fb393 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -526,6 +526,112 @@ func TestProviderChat_HistoryCanonicalizationMatrix(t *testing.T) { }) } +func TestProviderChat_DeepSeekDocsReplayRequirements(t *testing.T) { + // DeepSeek's thinking-mode and multi-round chat docs distinguish two cases: + // - for a plain assistant turn between two user messages without tool calls, + // reasoning_content does not need to be replayed and the API ignores it if sent; + // - for a turn that participates in a tool-interaction round, assistant + // reasoning_content must be replayed on subsequent requests. + // + // Keep this behavior explicit here so future changes do not "fix" the + // non-tool stripping based on issue reports that are broader than the + // vendor documentation. + var requestBody map[string]any + + 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]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"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, "") + p.SetProviderName("deepseek") + + messages := []Message{ + {Role: "user", Content: "Who wrote The Hobbit?"}, + {Role: "assistant", Content: "J.R.R. Tolkien.", ReasoningContent: "I know this from general knowledge."}, + {Role: "user", Content: "What's the weather tomorrow?"}, + { + Role: "assistant", + Content: "Let me check the date first.", + ReasoningContent: "I need tomorrow's date before checking the weather.", + ToolCalls: []ToolCall{{ + ID: "call_date", + Type: "function", + Function: &FunctionCall{ + Name: "get_date", + Arguments: "{}", + }, + }}, + }, + {Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"}, + { + Role: "assistant", + Content: "Tomorrow is 2026-04-30.", + ReasoningContent: "Now I can continue with the weather request.", + }, + {Role: "user", Content: "What about Guangzhou?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + if len(reqMessages) != len(messages) { + t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages)) + } + + plainAssistant, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("plain assistant message is not map[string]any: %T", reqMessages[1]) + } + if _, exists := plainAssistant["reasoning_content"]; exists { + t.Fatalf( + "plain DeepSeek turn should omit reasoning_content on replay, got %v", + plainAssistant["reasoning_content"], + ) + } + + toolAssistant, ok := reqMessages[3].(map[string]any) + if !ok { + t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[3]) + } + if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." { + t.Fatalf( + "tool assistant reasoning_content = %v, want preserved", + toolAssistant["reasoning_content"], + ) + } + + finalAssistant, ok := reqMessages[5].(map[string]any) + if !ok { + t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[5]) + } + if finalAssistant["reasoning_content"] != "Now I can continue with the weather request." { + t.Fatalf( + "final assistant reasoning_content = %v, want preserved", + finalAssistant["reasoning_content"], + ) + } +} + 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) diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go new file mode 100644 index 000000000..a9178cb81 --- /dev/null +++ b/pkg/providers/provider_catalog.go @@ -0,0 +1,181 @@ +package providers + +import ( + "sort" + "strings" +) + +// ModelProviderOption describes a canonical provider entry exposed to the Web UI. +type ModelProviderOption struct { + ID string `json:"id"` + DefaultAPIBase string `json:"default_api_base"` + EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` + CreateAllowed bool `json:"create_allowed"` + DefaultModelAllowed bool `json:"default_model_allowed"` + DefaultAuthMethod string `json:"default_auth_method,omitempty"` + AuthMethodLocked bool `json:"auth_method_locked,omitempty"` +} + +type attachedModelProviderMeta struct { + protocolMeta + createAllowed bool + defaultModelAllowed bool + defaultAuthMethod string + authMethodLocked bool +} + +// attachedModelProviderMetaByName augments protocolMetaByName for provider +// families that are implemented in CreateProviderFromConfig but intentionally +// kept out of the core HTTP metadata map because they have special auth/runtime +// semantics. +var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ + "azure": {createAllowed: true, defaultModelAllowed: true}, + "anthropic": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "anthropic-messages": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "bedrock": {createAllowed: true, defaultModelAllowed: true}, + "antigravity": { + createAllowed: true, + defaultModelAllowed: true, + defaultAuthMethod: "oauth", + authMethodLocked: true, + }, + "claude-cli": {createAllowed: true, defaultModelAllowed: true}, + "codex-cli": {createAllowed: true, defaultModelAllowed: true}, + "github-copilot": { + protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, + createAllowed: true, + defaultModelAllowed: true, + }, + // ElevenLabs is intentionally exposed only as an ASR-capable provider. It + // belongs in the shared model catalog because ASR is configured via + // model_list, but it must not be selectable as the default chat model. + "elevenlabs": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"}, + createAllowed: true, + defaultModelAllowed: false, + }, +} + +// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. +func ModelProviderOptions() []ModelProviderOption { + optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) + for provider := range protocolMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: DefaultAPIBaseForProtocol(provider), + EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), + CreateAllowed: true, + DefaultModelAllowed: true, + } + } + for provider, meta := range attachedModelProviderMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: meta.defaultAPIBase, + EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, + CreateAllowed: meta.createAllowed, + DefaultModelAllowed: meta.defaultModelAllowed, + DefaultAuthMethod: meta.defaultAuthMethod, + AuthMethodLocked: meta.authMethodLocked, + } + } + + options := make([]ModelProviderOption, 0, len(optionsByID)) + for _, option := range optionsByID { + options = append(options, option) + } + sort.Slice(options, func(i, j int) bool { + return options[i].ID < options[j].ID + }) + return options +} + +// IsSupportedModelProvider reports whether provider resolves to a provider ID +// returned by ModelProviderOptions. +func IsSupportedModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + _, ok := attachedModelProviderMetaByName[normalized] + return ok +} + +// IsCreatableModelProvider reports whether provider can be selected for a new +// model entry from the Web UI. +func IsCreatableModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.createAllowed +} + +// IsDefaultModelProvider reports whether provider can be used as the default +// chat model. Some providers such as ASR-only entries are intentionally +// exposed in model_list management but cannot drive the gateway default model. +func IsDefaultModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.defaultModelAllowed +} + +// SplitModelProviderAndID separates a legacy "provider/model" string into its +// effective provider and canonical model ID. Unknown prefixes are treated as +// part of the model ID and fall back to defaultProvider. +func SplitModelProviderAndID(model, defaultProvider string) (provider, modelID string) { + model = strings.TrimSpace(model) + if model == "" { + return "", "" + } + + provider, modelID = splitKnownProviderModel(model) + if provider != "" || modelID != "" { + return provider, modelID + } + + return NormalizeProvider(defaultProvider), model +} + +func splitKnownProviderModel(model string) (provider, modelID string) { + provider, modelID, found := strings.Cut(strings.TrimSpace(model), "/") + if !found { + return "", "" + } + provider = strings.TrimSpace(provider) + modelID = strings.TrimSpace(modelID) + if provider == "" { + return "", modelID + } + if !IsSupportedModelProvider(provider) { + return "", "" + } + return NormalizeProvider(provider), modelID +} diff --git a/pkg/providers/tool_schema_transform.go b/pkg/providers/tool_schema_transform.go new file mode 100644 index 000000000..6b6cab7a6 --- /dev/null +++ b/pkg/providers/tool_schema_transform.go @@ -0,0 +1,84 @@ +package providers + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/common" +) + +type toolSchemaTransformProvider struct { + delegate LLMProvider + transform string +} + +type toolSchemaStreamingProvider struct { + *toolSchemaTransformProvider +} + +func wrapProviderWithToolSchemaTransform(delegate LLMProvider, transform string) (LLMProvider, error) { + transform, err := common.NormalizeToolSchemaTransform(transform) + if err != nil { + return nil, err + } + if transform == common.ToolSchemaTransformOff || delegate == nil { + return delegate, nil + } + base := &toolSchemaTransformProvider{ + delegate: delegate, + transform: transform, + } + if _, ok := delegate.(StreamingProvider); ok { + return &toolSchemaStreamingProvider{toolSchemaTransformProvider: base}, nil + } + return base, nil +} + +func (p *toolSchemaTransformProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + transformed, err := common.TransformToolDefinitions(tools, p.transform) + if err != nil { + return nil, err + } + return p.delegate.Chat(ctx, messages, transformed, model, options) +} + +func (p *toolSchemaTransformProvider) GetDefaultModel() string { + return p.delegate.GetDefaultModel() +} + +func (p *toolSchemaStreamingProvider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + streaming := p.delegate.(StreamingProvider) + transformed, err := common.TransformToolDefinitions(tools, p.transform) + if err != nil { + return nil, err + } + return streaming.ChatStream(ctx, messages, transformed, model, options, onChunk) +} + +func (p *toolSchemaTransformProvider) SupportsThinking() bool { + tc, ok := p.delegate.(ThinkingCapable) + return ok && tc.SupportsThinking() +} + +func (p *toolSchemaTransformProvider) SupportsNativeSearch() bool { + ns, ok := p.delegate.(NativeSearchCapable) + return ok && ns.SupportsNativeSearch() +} + +func (p *toolSchemaTransformProvider) Close() { + if stateful, ok := p.delegate.(StatefulProvider); ok { + stateful.Close() + } +} diff --git a/pkg/providers/tool_schema_transform_test.go b/pkg/providers/tool_schema_transform_test.go new file mode 100644 index 000000000..a162c3cb4 --- /dev/null +++ b/pkg/providers/tool_schema_transform_test.go @@ -0,0 +1,104 @@ +package providers + +import ( + "context" + "reflect" + "testing" + + providercommon "github.com/sipeed/picoclaw/pkg/providers/common" +) + +type toolCaptureProvider struct { + lastTools []ToolDefinition +} + +func (p *toolCaptureProvider) Chat( + _ context.Context, + _ []Message, + tools []ToolDefinition, + _ string, + _ map[string]any, +) (*LLMResponse, error) { + p.lastTools = tools + return &LLMResponse{Content: "ok"}, nil +} + +func (p *toolCaptureProvider) GetDefaultModel() string { + return "test" +} + +func TestWrapProviderWithToolSchemaTransform_DisabledPassesToolsThrough(t *testing.T) { + capture := &toolCaptureProvider{} + wrapped, err := wrapProviderWithToolSchemaTransform(capture, "") + if err != nil { + t.Fatalf("wrapProviderWithToolSchemaTransform() error = %v", err) + } + + tools := []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "noop", + Parameters: map[string]any{"type": "object"}, + }, + }} + + _, err = wrapped.Chat(t.Context(), nil, tools, "test", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if !reflect.DeepEqual(capture.lastTools, tools) { + t.Fatalf("tools mutated with transform off\n got: %#v\nwant: %#v", capture.lastTools, tools) + } +} + +func TestWrapProviderWithToolSchemaTransform_GoogleSanitizesSchemas(t *testing.T) { + capture := &toolCaptureProvider{} + wrapped, err := wrapProviderWithToolSchemaTransform(capture, "simple") + if err != nil { + t.Fatalf("wrapProviderWithToolSchemaTransform() error = %v", err) + } + + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "parent": map[string]any{ + "anyOf": []any{ + map[string]any{"$ref": "#/$defs/pageParent"}, + map[string]any{"$ref": "#/$defs/databaseParent"}, + }, + }, + }, + "$defs": map[string]any{ + "pageParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "page_id": map[string]any{"type": "string"}, + }, + }, + "databaseParent": map[string]any{ + "type": "object", + "properties": map[string]any{ + "database_id": map[string]any{"type": "string"}, + }, + }, + }, + } + tools := []ToolDefinition{{ + Type: "function", + Function: ToolFunctionDefinition{ + Name: "mcp_notion_create", + Parameters: schema, + }, + }} + + _, err = wrapped.Chat(t.Context(), nil, tools, "test", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + want := providercommon.SanitizeSchemaForGoogle(schema) + got := capture.lastTools[0].Function.Parameters + if !reflect.DeepEqual(got, want) { + t.Fatalf("sanitized parameters mismatch\n got: %#v\nwant: %#v", got, want) + } +} diff --git a/pkg/seahorse/schema.go b/pkg/seahorse/schema.go index aa829358b..5b67fe9e0 100644 --- a/pkg/seahorse/schema.go +++ b/pkg/seahorse/schema.go @@ -46,6 +46,7 @@ func runSchema(db *sql.DB) error { conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id), role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', + reasoning_content TEXT NOT NULL DEFAULT '', token_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) )`, @@ -157,9 +158,57 @@ func runSchema(db *sql.DB) error { return err } } + + if err := ensureMessagesReasoningContentColumn(db); err != nil { + return err + } return nil } +func ensureMessagesReasoningContentColumn(db *sql.DB) error { + hasColumn, err := tableHasColumn(db, "messages", "reasoning_content") + if err != nil { + return fmt.Errorf("check messages.reasoning_content: %w", err) + } + if hasColumn { + return nil + } + + if _, err := db.Exec(`ALTER TABLE messages ADD COLUMN reasoning_content TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add messages.reasoning_content: %w", err) + } + return nil +} + +func tableHasColumn(db *sql.DB, tableName, columnName string) (bool, error) { + rows, err := db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, tableName)) + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var ( + cid int + name string + columnType string + notNull int + defaultVal sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil { + return false, err + } + if name == columnName { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, err + } + return false, nil +} + // checkFTS5Support verifies that SQLite has FTS5 with trigram tokenizer enabled. // This is required for full-text search with CJK (Chinese, Japanese, Korean) support. func checkFTS5Support(db *sql.DB) error { diff --git a/pkg/seahorse/schema_test.go b/pkg/seahorse/schema_test.go index f3d6a3650..943b742b2 100644 --- a/pkg/seahorse/schema_test.go +++ b/pkg/seahorse/schema_test.go @@ -91,6 +91,53 @@ func TestRunMigrationsIdempotent(t *testing.T) { } } +func TestRunSchemaAddsMessagesReasoningContentColumn(t *testing.T) { + db := openTestDB(t) + + _, err := db.Exec(`CREATE TABLE messages ( + message_id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + token_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`) + if err != nil { + t.Fatalf("create legacy messages table: %v", err) + } + + err = runSchema(db) + if err != nil { + t.Fatalf("runSchema: %v", err) + } + + var count int + err = db.QueryRow(`SELECT count(*) FROM pragma_table_info('messages') WHERE name = 'reasoning_content'`). + Scan(&count) + if err != nil { + t.Fatalf("query pragma_table_info: %v", err) + } + if count != 1 { + t.Fatalf("reasoning_content column count = %d, want 1", count) + } + + _, err = db.Exec( + `INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))`, + "reasoning-column-test", + ) + if err != nil { + t.Fatalf("insert conversation: %v", err) + } + + _, err = db.Exec( + `INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) + VALUES (1, 'assistant', 'answer', 'thinking', 1)`, + ) + if err != nil { + t.Fatalf("insert message with reasoning_content: %v", err) + } +} + func TestMigrationConversationUnique(t *testing.T) { db := openTestDB(t) if err := runSchema(db); err != nil { diff --git a/pkg/seahorse/short_compaction.go b/pkg/seahorse/short_compaction.go index 30e290926..0dfb1330f 100644 --- a/pkg/seahorse/short_compaction.go +++ b/pkg/seahorse/short_compaction.go @@ -602,8 +602,8 @@ func (e *CompactionEngine) generateLeafSummary( } } - // Check if level 1 succeeded - if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) < inputTokens { + // Level 1 only succeeds if it actually reaches the requested target size. + if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= targetTokens { return content, nil } @@ -627,7 +627,7 @@ func (e *CompactionEngine) generateLeafSummary( return "", err } } - if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) < inputTokens { + if content != "" && tokenizer.EstimateMessageTokens(providers.Message{Content: content}) <= aggressiveTarget { return content, nil } diff --git a/pkg/seahorse/short_compaction_test.go b/pkg/seahorse/short_compaction_test.go index ea7dcb52d..da07cdab7 100644 --- a/pkg/seahorse/short_compaction_test.go +++ b/pkg/seahorse/short_compaction_test.go @@ -3,6 +3,7 @@ package seahorse import ( "context" "fmt" + "strings" "sync" "sync/atomic" "testing" @@ -697,6 +698,69 @@ func TestGenerateLeafSummaryEscalationToAggressive(t *testing.T) { } } +func TestGenerateLeafSummaryEscalatesWhenLevel1MissesTarget(t *testing.T) { + var calls []string + normalContent := strings.Repeat("n", 1000) // ~404 tokens: below input, above target + aggressiveContent := strings.Repeat("a", 450) // ~184 tokens: within aggressive target + escalateComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + if contains(prompt, "Aggressive summary policy") { + calls = append(calls, "aggressive") + return aggressiveContent, nil + } + calls = append(calls, "normal") + return normalContent, nil + } + + s := openTestStore(t) + ce, _ := newTestCompactionEngineWithStore(s, escalateComplete) + + msgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 500}, + {Role: "assistant", Content: "response", TokenCount: 500}, + } + + content, err := ce.generateLeafSummary(context.Background(), msgs, "") + if err != nil { + t.Fatalf("generateLeafSummary: %v", err) + } + if content != aggressiveContent { + t.Fatalf("expected aggressive summary after level 1 missed target") + } + if len(calls) != 2 || calls[0] != "normal" || calls[1] != "aggressive" { + t.Fatalf("expected normal then aggressive calls, got %v", calls) + } +} + +func TestGenerateLeafSummaryAcceptsContentAtTargetBoundary(t *testing.T) { + exactTargetContent := strings.Repeat("x", 488) // (488 + 12) * 2 / 5 = 200 tokens + var aggressiveCalled bool + complete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { + if contains(prompt, "Aggressive summary policy") { + aggressiveCalled = true + } + return exactTargetContent, nil + } + + s := openTestStore(t) + ce, _ := newTestCompactionEngineWithStore(s, complete) + + msgs := []Message{ + {Role: "user", Content: "hello world", TokenCount: 286}, + {Role: "assistant", Content: "response", TokenCount: 286}, + } + + content, err := ce.generateLeafSummary(context.Background(), msgs, "") + if err != nil { + t.Fatalf("generateLeafSummary: %v", err) + } + if content != exactTargetContent { + t.Fatalf("expected level 1 summary at target boundary to be accepted") + } + if aggressiveCalled { + t.Fatal("did not expect aggressive retry when level 1 hit target exactly") + } +} + func TestGenerateLeafSummaryEscalationToTruncation(t *testing.T) { // Both normal and aggressive return empty, should escalate to level 3 truncation emptyComplete := func(ctx context.Context, prompt string, opts CompleteOptions) (string, error) { diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go index f584788ce..0a8175617 100644 --- a/pkg/seahorse/short_engine.go +++ b/pkg/seahorse/short_engine.go @@ -253,9 +253,23 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa var added *Message var err error if len(msg.Parts) > 0 { - added, err = e.store.AddMessageWithParts(ctx, conv.ConversationID, msg.Role, msg.Parts, msg.TokenCount) + added, err = e.store.AddMessageWithPartsAndReasoning( + ctx, + conv.ConversationID, + msg.Role, + msg.Parts, + msg.ReasoningContent, + msg.TokenCount, + ) } else { - added, err = e.store.AddMessage(ctx, conv.ConversationID, msg.Role, msg.Content, msg.TokenCount) + added, err = e.store.AddMessageWithReasoning( + ctx, + conv.ConversationID, + msg.Role, + msg.Content, + msg.ReasoningContent, + msg.TokenCount, + ) } if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -420,7 +434,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me // Fast path: DB has same count and exact match → no-op if len(dbMsgs) == len(messages) { matched := true - for i := 0; i < len(messages); i++ { + for i := range messages { if !messageMatches(dbMsgs[i], messages[i]) { matched = false break @@ -431,14 +445,21 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me } } - // Find longest matching prefix from the start - anchor := -1 - compareLen := len(dbMsgs) - if compareLen > len(messages) { - compareLen = len(messages) + // Migration repair path: old SeaHorse rows may be missing reasoning_content + // even though the canonical JSONL history already has it. Backfill those + // rows in place so we do not treat this as edited history and leave stale + // summaries/context behind after a partial raw-message rebuild. + if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil { + return fmt.Errorf("bootstrap: repair reasoning_content: %w", err) + } else if repaired && len(dbMsgs) == len(messages) { + return nil } - for i := 0; i < compareLen; i++ { + // Find longest matching prefix from the start + anchor := -1 + compareLen := min(len(dbMsgs), len(messages)) + + for i := range compareLen { if messageMatches(dbMsgs[i], messages[i]) { anchor = i } else { @@ -524,6 +545,57 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me return nil } +func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, messages []Message) (bool, error) { + if len(dbMsgs) == 0 || len(messages) == 0 { + return false, nil + } + + overlap := min(len(messages), len(dbMsgs)) + + var updates []struct { + index int + messageID int64 + reasoningContent string + } + + for i := range overlap { + if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) { + return false, nil + } + if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent { + continue + } + if dbMsgs[i].ReasoningContent != "" || messages[i].ReasoningContent == "" { + return false, nil + } + updates = append(updates, struct { + index int + messageID int64 + reasoningContent string + }{ + index: i, + messageID: dbMsgs[i].ID, + reasoningContent: messages[i].ReasoningContent, + }) + } + + if len(updates) == 0 { + return false, nil + } + + for _, update := range updates { + if err := e.store.UpdateMessageReasoningContent(ctx, update.messageID, update.reasoningContent); err != nil { + return false, err + } + dbMsgs[update.index].ReasoningContent = update.reasoningContent + } + + logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", map[string]any{ + "messages": len(updates), + }) + return true, nil +} + // truncate shortens a string for logging. func truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -532,12 +604,19 @@ func truncate(s string, maxLen int) string { return s[:maxLen] + "..." } -// messageMatches compares two messages using (role, content) or (role, parts). -// TokenCount is NOT compared because it may be re-estimated differently -// during bootstrap (e.g., via tokenizer.EstimateMessageTokens). +// messageMatches compares two messages using role + reasoning_content and then +// either content or parts. TokenCount is NOT compared because it may be +// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens). // For messages with Parts (tool_use, tool_result), compare Parts instead of Content -// since AddMessageWithParts stores empty Content in DB. +// because structured messages are matched by their parts payload. func messageMatches(a, b Message) bool { + if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent { + return false + } + return messageMatchesIgnoringReasoning(a, b) +} + +func messageMatchesIgnoringReasoning(a, b Message) bool { if a.Role != b.Role { return false } diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index d64634fb7..2a5c6c5d8 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -320,6 +320,108 @@ func TestEngineIngestWithParts(t *testing.T) { } } +func TestEngineIngestPreservesReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + { + Role: "assistant", + Content: "world", + ReasoningContent: "let me think this through", + TokenCount: 4, + }, + } + + _, err := eng.Ingest(ctx, "agent:reasoning", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:reasoning") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if stored[0].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[0].ReasoningContent = %q, want %q", + stored[0].ReasoningContent, + "let me think this through", + ) + } + + result, err := eng.Assemble(ctx, "agent:reasoning", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("assembled messages = %d, want 1", len(result.Messages)) + } + if result.Messages[0].ReasoningContent != "let me think this through" { + t.Errorf( + "assembled reasoning = %q, want %q", + result.Messages[0].ReasoningContent, + "let me think this through", + ) + } +} + +func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + { + Role: "assistant", + ReasoningContent: "I need to inspect the file first", + TokenCount: 10, + Parts: []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + }, + }, + } + + _, err := eng.Ingest(ctx, "agent:parts-reasoning", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-reasoning") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if stored[0].ReasoningContent != "I need to inspect the file first" { + t.Errorf( + "stored reasoning = %q, want %q", + stored[0].ReasoningContent, + "I need to inspect the file first", + ) + } + + result, err := eng.Assemble(ctx, "agent:parts-reasoning", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("assembled messages = %d, want 1", len(result.Messages)) + } + if result.Messages[0].ReasoningContent != "I need to inspect the file first" { + t.Errorf( + "assembled reasoning = %q, want %q", + result.Messages[0].ReasoningContent, + "I need to inspect the file first", + ) + } +} + func TestEngineIngestAssemblePreservesParts(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() @@ -514,6 +616,216 @@ func TestEngineBootstrapIdempotent(t *testing.T) { } } +func TestBootstrapRepairsMissingReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } +} + +func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning-summary" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + summary, err := eng.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "summary before repair", + TokenCount: 10, + }) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + + err = eng.store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID) + if err != nil { + t.Fatalf("AppendContextSummary: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } + + summaries, err := eng.store.GetSummariesByConversation(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetSummariesByConversation: %v", err) + } + if len(summaries) != 1 { + t.Fatalf("summaries = %d, want 1", len(summaries)) + } + if summaries[0].SummaryID != summary.SummaryID { + t.Errorf("SummaryID = %q, want %q", summaries[0].SummaryID, summary.SummaryID) + } + + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("context items = %d, want 3", len(items)) + } + if items[2].ItemType != "summary" || items[2].SummaryID != summary.SummaryID { + t.Errorf("summary context item = %+v, want summary %q", items[2], summary.SummaryID) + } +} + +func TestBootstrapRepairsMissingReasoningContentOnPrefixBeforeAppendingDelta(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning-prefix" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + {Role: "user", Content: "follow-up", TokenCount: 2}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 3 { + t.Fatalf("stored messages = %d, want 3", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } + if stored[2].Content != "follow-up" { + t.Errorf("stored[2].Content = %q, want %q", stored[2].Content, "follow-up") + } + + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("context items = %d, want 3", len(items)) + } + if items[2].ItemType != "message" || items[2].MessageID != stored[2].ID { + t.Errorf("last context item = %+v, want appended message %d", items[2], stored[2].ID) + } +} + func TestEngineBootstrapDelta(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go index c84aaaf07..0edbbd128 100644 --- a/pkg/seahorse/store.go +++ b/pkg/seahorse/store.go @@ -162,20 +162,31 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim // AddMessage appends a message to a conversation. func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) { + return s.AddMessageWithReasoning(ctx, convID, role, content, "", tokenCount) +} + +// AddMessageWithReasoning appends a message with reasoning content to a conversation. +func (s *Store) AddMessageWithReasoning( + ctx context.Context, + convID int64, + role, content, reasoningContent string, + tokenCount int, +) (*Message, error) { result, err := s.db.ExecContext(ctx, - "INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)", - convID, role, content, tokenCount, + "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", + convID, role, content, reasoningContent, tokenCount, ) if err != nil { return nil, fmt.Errorf("add message: %w", err) } id, _ := result.LastInsertId() return &Message{ - ID: id, - ConversationID: convID, - Role: role, - Content: content, - TokenCount: tokenCount, + ID: id, + ConversationID: convID, + Role: role, + Content: content, + ReasoningContent: reasoningContent, + TokenCount: tokenCount, }, nil } @@ -212,6 +223,18 @@ func (s *Store) AddMessageWithParts( role string, parts []MessagePart, tokenCount int, +) (*Message, error) { + return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount) +} + +// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content. +func (s *Store) AddMessageWithPartsAndReasoning( + ctx context.Context, + convID int64, + role string, + parts []MessagePart, + reasoningContent string, + tokenCount int, ) (*Message, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -223,8 +246,8 @@ func (s *Store) AddMessageWithParts( readableContent := partsToReadableContent(parts) result, err := tx.ExecContext(ctx, - "INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)", - convID, role, readableContent, tokenCount, + "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", + convID, role, readableContent, reasoningContent, tokenCount, ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -256,11 +279,12 @@ func (s *Store) AddMessageWithParts( // Return message with parts msg := &Message{ - ID: msgID, - ConversationID: convID, - Role: role, - TokenCount: tokenCount, - Parts: make([]MessagePart, len(parts)), + ID: msgID, + ConversationID: convID, + Role: role, + ReasoningContent: reasoningContent, + TokenCount: tokenCount, + Parts: make([]MessagePart, len(parts)), } for i, p := range parts { p.MessageID = msgID @@ -271,7 +295,7 @@ func (s *Store) AddMessageWithParts( // GetMessages retrieves messages for a conversation. func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) { - query := "SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE conversation_id = ?" + query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?" args := []any{convID} if beforeID > 0 { query += " AND message_id < ?" @@ -298,6 +322,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before &msg.ConversationID, &msg.Role, &msg.Content, + &msg.ReasoningContent, &msg.TokenCount, &createdAt, ); err != nil { @@ -335,10 +360,11 @@ func (s *Store) GetMessageCount(ctx context.Context, convID int64) (int, error) func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) { var msg Message var createdAt string - err := s.db.QueryRowContext(ctx, - "SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE message_id = ?", + err := s.db.QueryRowContext( + ctx, + "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?", messageID, - ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.TokenCount, &createdAt) + ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("message %d not found", messageID) } @@ -350,6 +376,28 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, return &msg, nil } +// UpdateMessageReasoningContent updates reasoning_content for an existing message. +func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error { + result, err := s.db.ExecContext( + ctx, + "UPDATE messages SET reasoning_content = ? WHERE message_id = ?", + reasoningContent, + messageID, + ) + if err != nil { + return fmt.Errorf("update message reasoning_content: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update message reasoning_content rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("message %d not found", messageID) + } + return nil +} + func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) { rows, err := s.db.QueryContext(ctx, `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type @@ -534,7 +582,7 @@ func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, mes // GetSummarySourceMessages retrieves source messages for a summary. func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) { rows, err := s.db.QueryContext(ctx, - `SELECT m.message_id, m.conversation_id, m.role, m.content, m.token_count, m.created_at + `SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at FROM summary_messages sm JOIN messages m ON m.message_id = sm.message_id WHERE sm.summary_id = ? @@ -555,6 +603,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) &msg.ConversationID, &msg.Role, &msg.Content, + &msg.ReasoningContent, &msg.TokenCount, &createdAt, ); err != nil { diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go index 89635cc9a..67bed1c11 100644 --- a/pkg/seahorse/store_test.go +++ b/pkg/seahorse/store_test.go @@ -199,6 +199,47 @@ func TestStoreAddAndGetMessages(t *testing.T) { } } +func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:reasoning") + + msg, err := s.AddMessageWithReasoning( + ctx, + conv.ConversationID, + "assistant", + "hello world", + "let me think", + 5, + ) + if err != nil { + t.Fatalf("AddMessageWithReasoning: %v", err) + } + if msg.ReasoningContent != "let me think" { + t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think") + } + + msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].ReasoningContent != "let me think" { + t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think") + } + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.ReasoningContent != "let me think" { + t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think") + } +} + func TestStoreAddMessageWithParts(t *testing.T) { s := openTestStore(t) ctx := context.Background() @@ -233,6 +274,43 @@ func TestStoreAddMessageWithParts(t *testing.T) { } } +func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:parts-reasoning") + + parts := []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + } + _, err := s.AddMessageWithPartsAndReasoning( + ctx, + conv.ConversationID, + "assistant", + parts, + "need to inspect the file first", + 10, + ) + if err != nil { + t.Fatalf("AddMessageWithPartsAndReasoning: %v", err) + } + + msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].ReasoningContent != "need to inspect the file first" { + t.Errorf( + "ReasoningContent = %q, want %q", + msgs[0].ReasoningContent, + "need to inspect the file first", + ) + } +} + func TestStoreGetMessageCount(t *testing.T) { s := openTestStore(t) ctx := context.Background() @@ -275,6 +353,31 @@ func TestStoreGetMessageByID(t *testing.T) { } } +func TestStoreUpdateMessageReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning") + + msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking") + if err != nil { + t.Fatalf("UpdateMessageReasoningContent: %v", err) + } + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.ReasoningContent != "thinking" { + t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking") + } +} + // --- Summary Operations --- func TestStoreCreateAndGetSummary(t *testing.T) { diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index f2e6561df..a9547eba9 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -357,7 +357,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } if response != "" { - t.executor.PublishResponseIfNeeded(ctx, channel, chatID, "", response) + t.executor.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response) } return "ok" } diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index d46d365a0..0e527c98a 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -24,6 +24,7 @@ type stubJobExecutor struct { publishedResp string publishedChan string publishedChatID string + publishedKey string } func (s *stubJobExecutor) ProcessDirectWithChannel( @@ -47,6 +48,7 @@ func (s *stubJobExecutor) PublishResponseIfNeeded( s.publishedResp = response s.publishedChan = channel s.publishedChatID = chatID + s.publishedKey = sessionKey } func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { @@ -283,6 +285,9 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { if executor.publishedResp != "generated reply" { t.Fatalf("published response = %q, want generated reply", executor.publishedResp) } + if executor.publishedKey != executor.lastKey { + t.Fatalf("published sessionKey = %q, want %q", executor.publishedKey, executor.lastKey) + } if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID) } diff --git a/pkg/tools/delegate.go b/pkg/tools/delegate.go new file mode 100644 index 000000000..dcde27718 --- /dev/null +++ b/pkg/tools/delegate.go @@ -0,0 +1,104 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/routing" +) + +// DelegateTool delegates a task to a specific named agent and waits for +// the result. Unlike spawn (async, fire-and-forget) or subagent (sync but +// generic), delegate targets a named agent and runs the task using that +// agent's own workspace, model, and tools. +type DelegateTool struct { + spawner SubTurnSpawner + allowlistCheck func(targetAgentID string) bool + selfAgentID string +} + +func NewDelegateTool() *DelegateTool { + return &DelegateTool{} +} + +func (t *DelegateTool) SetSpawner(spawner SubTurnSpawner) { + t.spawner = spawner +} + +func (t *DelegateTool) SetAllowlistChecker(check func(targetAgentID string) bool) { + t.allowlistCheck = check +} + +func (t *DelegateTool) SetSelfAgentID(id string) { + t.selfAgentID = id +} + +func (t *DelegateTool) Name() string { + return "delegate" +} + +func (t *DelegateTool) Description() string { + return "Delegate a task to another agent and wait for the result. " + + "Use this when another agent is better suited to handle a specific task " + + "based on their capabilities. The target agent runs with its own workspace, " + + "model, and tools." +} + +func (t *DelegateTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "agent_id": map[string]any{ + "type": "string", + "description": "The ID of the target agent to delegate the task to", + }, + "task": map[string]any{ + "type": "string", + "description": "Clear description of the task to delegate", + }, + }, + "required": []string{"agent_id", "task"}, + } +} + +func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + rawAgentID, _ := args["agent_id"].(string) + if strings.TrimSpace(rawAgentID) == "" { + return ErrorResult("agent_id is required and must be a non-empty string") + } + agentID := routing.NormalizeAgentID(rawAgentID) + + task, _ := args["task"].(string) + if strings.TrimSpace(task) == "" { + return ErrorResult("task is required and must be a non-empty string") + } + + if t.selfAgentID != "" && agentID == t.selfAgentID { + return ErrorResult("cannot delegate to self") + } + + if t.allowlistCheck != nil && !t.allowlistCheck(agentID) { + return ErrorResult(fmt.Sprintf("not allowed to delegate to agent %q", agentID)) + } + + if t.spawner == nil { + return ErrorResult("delegate tool not configured") + } + + result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{ + TargetAgentID: agentID, + SystemPrompt: task, + Async: false, + }) + if err != nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q failed: %v", agentID, err)).WithError(err) + } + if result == nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q returned no result", agentID)) + } + + result.ForLLM = fmt.Sprintf("[Response from agent %q]\n%s", agentID, result.ForLLM) + + return result +} diff --git a/pkg/tools/delegate_test.go b/pkg/tools/delegate_test.go new file mode 100644 index 000000000..729c524a7 --- /dev/null +++ b/pkg/tools/delegate_test.go @@ -0,0 +1,300 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// delegateMockSpawner records the config and returns a canned result. +type delegateMockSpawner struct { + lastCfg SubTurnConfig + result *ToolResult + err error +} + +func (m *delegateMockSpawner) SpawnSubTurn(_ context.Context, cfg SubTurnConfig) (*ToolResult, error) { + m.lastCfg = cfg + if m.err != nil { + return nil, m.err + } + if m.result != nil { + return m.result, nil + } + return &ToolResult{ + ForLLM: "completed: " + cfg.SystemPrompt, + ForUser: "completed", + }, nil +} + +func TestDelegateTool_Name(t *testing.T) { + tool := NewDelegateTool() + if tool.Name() != "delegate" { + t.Errorf("Name() = %q, want %q", tool.Name(), "delegate") + } +} + +func TestDelegateTool_Parameters(t *testing.T) { + tool := NewDelegateTool() + params := tool.Parameters() + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("properties should be a map") + } + _, hasAgentID := props["agent_id"] + if !hasAgentID { + t.Error("agent_id parameter should exist") + } + _, hasTask := props["task"] + if !hasTask { + t.Error("task parameter should exist") + } + + required, ok := params["required"].([]string) + if !ok { + t.Fatal("required should be a string array") + } + if len(required) != 2 { + t.Fatalf("required should have 2 entries, got %d", len(required)) + } +} + +func TestDelegateTool_Execute_Success(t *testing.T) { + spawner := &delegateMockSpawner{} + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "summarize the logs", + }) + + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, `[Response from agent "researcher"]`) { + t.Errorf("result should contain attribution, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "summarize the logs") { + t.Errorf("result should contain task output, got: %s", result.ForLLM) + } + + // Verify spawner received correct config + if spawner.lastCfg.TargetAgentID != "researcher" { + t.Errorf("TargetAgentID = %q, want %q", spawner.lastCfg.TargetAgentID, "researcher") + } + if spawner.lastCfg.Async { + t.Error("delegate should be synchronous (Async=false)") + } + if spawner.lastCfg.SystemPrompt != "summarize the logs" { + t.Errorf("SystemPrompt = %q, want %q", spawner.lastCfg.SystemPrompt, "summarize the logs") + } +} + +func TestDelegateTool_Execute_EmptyAgentID(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"task": "test"}}, + {"empty string", map[string]any{"agent_id": "", "task": "test"}}, + {"whitespace only", map[string]any{"agent_id": " ", "task": "test"}}, + {"wrong type", map[string]any{"agent_id": 123, "task": "test"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid agent_id") + } + if !strings.Contains(result.ForLLM, "agent_id is required") { + t.Errorf("error should mention agent_id, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_EmptyTask(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"agent_id": "a"}}, + {"empty string", map[string]any{"agent_id": "a", "task": ""}}, + {"whitespace only", map[string]any{"agent_id": "a", "task": "\t\n"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid task") + } + if !strings.Contains(result.ForLLM, "task is required") { + t.Errorf("error should mention task, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_PermissionDenied(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "forbidden-agent", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for denied agent") + } + if !strings.Contains(result.ForLLM, "not allowed to delegate") { + t.Errorf("error should mention permission, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_PermissionAllowed(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "allowed-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success for allowed agent, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoSpawner(t *testing.T) { + tool := NewDelegateTool() + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "a", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner is nil") + } + if !strings.Contains(result.ForLLM, "not configured") { + t.Errorf("error should mention not configured, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SpawnerError(t *testing.T) { + spawner := &delegateMockSpawner{ + err: fmt.Errorf("context deadline exceeded"), + } + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner fails") + } + if !strings.Contains(result.ForLLM, "delegation to agent") { + t.Errorf("error should mention delegation failure, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "context deadline exceeded") { + t.Errorf("error should propagate cause, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoAllowlistCheck(t *testing.T) { + // When no allowlist checker is set, all agents are allowed + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "any-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success without allowlist, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NilResult(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&nilResultSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for nil result") + } + if !strings.Contains(result.ForLLM, "returned no result") { + t.Errorf("error should mention no result, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SelfDelegation(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "alpha", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for self-delegation") + } + if !strings.Contains(result.ForLLM, "cannot delegate to self") { + t.Errorf("error should mention self-delegation, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SelfDelegation_Normalized(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") // stored normalized + + // Case-insensitive and whitespace variants should still be caught + variants := []string{"ALPHA", " Alpha ", " alpha "} + for _, v := range variants { + t.Run(v, func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": v, + "task": "test", + }) + if !result.IsError { + t.Errorf("agent_id=%q should be caught as self-delegation", v) + } + }) + } +} + +// nilResultSpawner always returns (nil, nil). +type nilResultSpawner struct{} + +func (m *nilResultSpawner) SpawnSubTurn(_ context.Context, _ SubTurnConfig) (*ToolResult, error) { + return nil, nil +} diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go index 672554209..378462512 100644 --- a/pkg/tools/facade_compat_test.go +++ b/pkg/tools/facade_compat_test.go @@ -9,6 +9,9 @@ func TestFacadeConstructorsRemainAvailable(t *testing.T) { if NewSPITool() == nil { t.Fatal("NewSPITool should return a tool") } + if NewSerialTool() == nil { + t.Fatal("NewSerialTool should return a tool") + } if NewMessageTool() == nil { t.Fatal("NewMessageTool should return a tool") } diff --git a/pkg/tools/fs/load_image.go b/pkg/tools/fs/load_image.go index 6f612faea..0a67fa120 100644 --- a/pkg/tools/fs/load_image.go +++ b/pkg/tools/fs/load_image.go @@ -147,10 +147,10 @@ func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolR return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err)) } - // Build the tool result text. The media:// ref will be picked up by - // resolveMediaRefs in loop_media.go and converted to a base64 data URL - // before the next LLM call, exactly like channel-received images. - msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref) + // Build the tool result text. The media:// ref in Media will be picked + // up by resolveMediaRefs in agent_media.go and base64-encoded for tool + // result messages (role="tool"), so the LLM can see the image content. + msg := fmt.Sprintf("Image loaded: %s\n[image: photo]", filename) return &ToolResult{ ForLLM: msg, diff --git a/pkg/tools/fs/load_image_test.go b/pkg/tools/fs/load_image_test.go index 72f163d81..d33db73be 100644 --- a/pkg/tools/fs/load_image_test.go +++ b/pkg/tools/fs/load_image_test.go @@ -135,9 +135,10 @@ func TestLoadImage_SuccessPath(t *testing.T) { t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM) } - // 4. ForLLM should also contain the media:// ref - if !strings.Contains(result.ForLLM, result.Media[0]) { - t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM) + // 4. ForLLM should contain the generic [image: photo] placeholder + // (resolveMediaRefs will replace it with the actual path later) + if !strings.Contains(result.ForLLM, "[image: photo]") { + t.Errorf("expected ForLLM to contain '[image: photo]' placeholder, got: %s", result.ForLLM) } // 5. Verify the ref is resolvable in the store diff --git a/pkg/tools/hardware/serial.go b/pkg/tools/hardware/serial.go new file mode 100644 index 000000000..7e197a909 --- /dev/null +++ b/pkg/tools/hardware/serial.go @@ -0,0 +1,453 @@ +package hardwaretools + +import ( + "context" + "encoding/json" + "fmt" + "math" + "regexp" + "runtime" + "strings" + "time" + "unicode/utf8" +) + +const ( + defaultSerialBaud = 115200 + defaultSerialDataBits = 8 + defaultSerialStopBits = 1 + defaultSerialTimeoutMS = 1000 + maxSerialPayloadBytes = 4096 + maxSerialReadBytes = 4096 + serialPollInterval = 100 * time.Millisecond +) + +var ( + unixSerialPortPattern = regexp.MustCompile( + `^(?:/dev/)?(?:ttyS\d+|ttyUSB\d+|ttyACM\d+|ttyAMA\d+|rfcomm\d+|tty\.[A-Za-z0-9._-]+|cu\.[A-Za-z0-9._-]+)$`, + ) + windowsSerialPortPattern = regexp.MustCompile(`^(?:\\\\\.\\)?COM[1-9]\d*$`) + unixSerialBaudRates = map[int]struct{}{ + 50: {}, 75: {}, 110: {}, 134: {}, 150: {}, 200: {}, 300: {}, 600: {}, 1200: {}, 1800: {}, + 2400: {}, 4800: {}, 9600: {}, 19200: {}, 38400: {}, 57600: {}, 115200: {}, 230400: {}, + } +) + +type SerialTool struct{} + +type serialPortInfo struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type serialConfig struct { + Port string + Baud int + DataBits int + Parity string + StopBits int +} + +func NewSerialTool() *SerialTool { + return &SerialTool{} +} + +func (t *SerialTool) Name() string { + return "serial" +} + +func (t *SerialTool) Description() string { + return "Interact with host serial ports. Actions: list (enumerate ports), read (receive bytes), write (send bytes with explicit confirmation). Supports Linux, macOS, and Windows." +} + +func (t *SerialTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"list", "read", "write"}, + "description": "Action to perform: list available serial ports, read bytes from a port, or write bytes to a port.", + }, + "port": map[string]any{ + "type": "string", + "description": "Serial port path or name, for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3. Required for read/write.", + }, + "baud": map[string]any{ + "type": "integer", + "description": "Baud rate. Default: 115200. Linux/macOS currently support standard termios rates up to 230400; Windows accepts configured rates up to 4000000.", + }, + "data_bits": map[string]any{ + "type": "integer", + "description": "Data bits. Supported values: 5, 6, 7, 8. Default: 8.", + }, + "parity": map[string]any{ + "type": "string", + "enum": []string{"none", "even", "odd"}, + "description": "Parity mode. Default: none.", + }, + "stop_bits": map[string]any{ + "type": "integer", + "description": "Stop bits. Supported values: 1, 2. Default: 1.", + }, + "timeout_ms": map[string]any{ + "type": "integer", + "description": "Read/write timeout in milliseconds. Default: 1000.", + }, + "length": map[string]any{ + "type": "integer", + "description": "Number of bytes to read. Required for read. Range: 1-4096.", + }, + "data": map[string]any{ + "type": "array", + "items": map[string]any{"type": "integer"}, + "description": "Bytes to write, each in range 0-255. Required for write unless text is provided.", + }, + "text": map[string]any{ + "type": "string", + "description": "UTF-8 text to write. Required for write if data is omitted.", + }, + "confirm": map[string]any{ + "type": "boolean", + "description": "Must be true for write operations.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *SerialTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, ok := args["action"].(string) + if !ok || strings.TrimSpace(action) == "" { + return ErrorResult("action is required") + } + + switch action { + case "list": + return t.list() + case "read": + return t.read(ctx, args) + case "write": + return t.write(ctx, args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, read, write)", action)) + } +} + +func (t *SerialTool) list() *ToolResult { + ports, err := serialListPorts() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to list serial ports: %v", err)) + } + if len(ports) == 0 { + return SilentResult("No serial ports found on this host.") + } + + result, _ := json.MarshalIndent(map[string]any{ + "ports": ports, + "count": len(ports), + }, "", " ") + return SilentResult(string(result)) +} + +func (t *SerialTool) read(ctx context.Context, args map[string]any) *ToolResult { + cfg, errResult := parseSerialConfig(args) + if errResult != nil { + return errResult + } + + length := 0 + if v, ok := args["length"].(float64); ok { + length = int(v) + } + if length < 1 || length > maxSerialReadBytes { + return ErrorResult(fmt.Sprintf("length is required for read (1-%d)", maxSerialReadBytes)) + } + + timeout, errResult := parseSerialTimeout(args) + if errResult != nil { + return errResult + } + + data, err := serialRead(ctx, cfg, length, timeout) + if err != nil { + return ErrorResult(fmt.Sprintf("serial read failed on %s: %v", cfg.Port, err)) + } + + return SilentResult(formatSerialPayload("read", cfg, data, timeout)) +} + +func (t *SerialTool) write(ctx context.Context, args map[string]any) *ToolResult { + confirm, _ := args["confirm"].(bool) + if !confirm { + return ErrorResult( + "write operations require confirm: true. Please confirm with the user before sending bytes to a serial device.", + ) + } + + cfg, errResult := parseSerialConfig(args) + if errResult != nil { + return errResult + } + timeout, errResult := parseSerialTimeout(args) + if errResult != nil { + return errResult + } + payload, errResult := parseSerialWritePayload(args) + if errResult != nil { + return errResult + } + + written, err := serialWrite(ctx, cfg, payload, timeout) + if err != nil { + return ErrorResult(fmt.Sprintf("serial write failed on %s: %v", cfg.Port, err)) + } + + result, _ := json.MarshalIndent(map[string]any{ + "action": "write", + "port": cfg.Port, + "baud": cfg.Baud, + "data_bits": cfg.DataBits, + "parity": cfg.Parity, + "stop_bits": cfg.StopBits, + "timeout_ms": timeout.Milliseconds(), + "written": written, + "payload": serialPayloadSummary(payload), + }, "", " ") + return SilentResult(string(result)) +} + +func parseSerialConfig(args map[string]any) (serialConfig, *ToolResult) { + port, ok := args["port"].(string) + port = strings.TrimSpace(port) + if !ok || port == "" { + return serialConfig{}, ErrorResult( + "port is required (for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3)", + ) + } + + normalizedPort, err := normalizeSerialPort(port) + if err != nil { + return serialConfig{}, ErrorResult(err.Error()) + } + + cfg := serialConfig{ + Port: normalizedPort, + Baud: defaultSerialBaud, + DataBits: defaultSerialDataBits, + Parity: "none", + StopBits: defaultSerialStopBits, + } + + if v, ok := args["baud"].(float64); ok { + cfg.Baud = int(v) + } + if err := validateSerialBaud(cfg.Baud); err != nil { + return serialConfig{}, ErrorResult(err.Error()) + } + + if v, ok := args["data_bits"].(float64); ok { + cfg.DataBits = int(v) + } + switch cfg.DataBits { + case 5, 6, 7, 8: + default: + return serialConfig{}, ErrorResult("data_bits must be one of 5, 6, 7, or 8") + } + + if v, ok := args["parity"].(string); ok && strings.TrimSpace(v) != "" { + cfg.Parity = strings.ToLower(strings.TrimSpace(v)) + } + switch cfg.Parity { + case "none", "even", "odd": + default: + return serialConfig{}, ErrorResult(`parity must be one of "none", "even", or "odd"`) + } + + if v, ok := args["stop_bits"].(float64); ok { + cfg.StopBits = int(v) + } + if cfg.StopBits != 1 && cfg.StopBits != 2 { + return serialConfig{}, ErrorResult("stop_bits must be 1 or 2") + } + + return cfg, nil +} + +func parseSerialTimeout(args map[string]any) (time.Duration, *ToolResult) { + timeoutMS := defaultSerialTimeoutMS + if v, ok := args["timeout_ms"].(float64); ok { + timeoutMS = int(v) + } + if timeoutMS < 1 || timeoutMS > 60000 { + return 0, ErrorResult("timeout_ms must be between 1 and 60000") + } + return time.Duration(timeoutMS) * time.Millisecond, nil +} + +func parseSerialWritePayload(args map[string]any) ([]byte, *ToolResult) { + if text, ok := args["text"].(string); ok && text != "" { + if !utf8.ValidString(text) { + return nil, ErrorResult("text must be valid UTF-8") + } + if len(text) > maxSerialPayloadBytes { + return nil, ErrorResult(fmt.Sprintf("text payload too large: maximum %d bytes", maxSerialPayloadBytes)) + } + return []byte(text), nil + } + + dataRaw, ok := args["data"].([]any) + if !ok || len(dataRaw) == 0 { + return nil, ErrorResult("write requires either text or data") + } + if len(dataRaw) > maxSerialPayloadBytes { + return nil, ErrorResult(fmt.Sprintf("data too long: maximum %d bytes", maxSerialPayloadBytes)) + } + + data := make([]byte, len(dataRaw)) + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return nil, ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + if f != math.Trunc(f) { + return nil, ErrorResult(fmt.Sprintf("data[%d] is not an integer byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return nil, ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + data[i] = byte(b) + } + + return data, nil +} + +func formatSerialPayload(action string, cfg serialConfig, data []byte, timeout time.Duration) string { + result, _ := json.MarshalIndent(map[string]any{ + "action": action, + "port": cfg.Port, + "baud": cfg.Baud, + "data_bits": cfg.DataBits, + "parity": cfg.Parity, + "stop_bits": cfg.StopBits, + "timeout_ms": timeout.Milliseconds(), + "payload": serialPayloadSummary(data), + }, "", " ") + return string(result) +} + +func serialPayloadSummary(data []byte) map[string]any { + hexValues := make([]string, len(data)) + intValues := make([]int, len(data)) + for i, b := range data { + hexValues[i] = fmt.Sprintf("0x%02x", b) + intValues[i] = int(b) + } + + summary := map[string]any{ + "length": len(data), + "bytes": intValues, + "hex": hexValues, + } + if utf8.Valid(data) { + summary["text"] = string(data) + } + return summary +} + +func normalizeSerialPort(port string) (string, error) { + switch runtime.GOOS { + case "windows": + return normalizeWindowsSerialPath(port) + case "linux", "darwin": + return normalizeUnixSerialPath(port) + default: + if normalized, err := normalizeUnixSerialPath(port); err == nil { + return normalized, nil + } + return normalizeWindowsSerialPath(port) + } +} + +func normalizeUnixSerialPath(port string) (string, error) { + trimmed := strings.TrimSpace(port) + if !unixSerialPortPattern.MatchString(trimmed) { + return "", fmt.Errorf( + "invalid serial port: expected a safe Unix device name such as /dev/ttyUSB0 or /dev/cu.usbserial-0001", + ) + } + if strings.HasPrefix(trimmed, "/dev/") { + return trimmed, nil + } + return "/dev/" + trimmed, nil +} + +func normalizeWindowsSerialPath(port string) (string, error) { + trimmed := strings.ToUpper(strings.TrimSpace(port)) + if !windowsSerialPortPattern.MatchString(trimmed) { + return "", fmt.Errorf("invalid serial port: expected a COM port such as COM3") + } + if strings.HasPrefix(trimmed, `\\.\`) { + return trimmed, nil + } + return `\\.\` + trimmed, nil +} + +func validateSerialBaud(baud int) error { + if baud < 50 || baud > 4000000 { + return fmt.Errorf("baud must be between 50 and 4000000") + } + + switch runtime.GOOS { + case "linux", "darwin": + if _, ok := unixSerialBaudRates[baud]; !ok { + return fmt.Errorf("unsupported baud rate on this platform: %d (supported up to 230400)", baud) + } + } + + return nil +} + +func serialContextErr(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} + +func serialWriteAll( + ctx context.Context, + data []byte, + timeout time.Duration, + now func() time.Time, + write func([]byte) (int, error), +) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + total := 0 + deadline := now().Add(timeout) + for total < len(data) { + if err := serialContextErr(ctx); err != nil { + return total, err + } + if deadline.Sub(now()) <= 0 { + return total, fmt.Errorf("timeout while writing serial data") + } + + n, err := write(data[total:]) + total += n + if err != nil { + return total, err + } + if n == 0 { + continue + } + } + + return total, nil +} diff --git a/pkg/tools/hardware/serial_darwin.go b/pkg/tools/hardware/serial_darwin.go new file mode 100644 index 000000000..bc019029e --- /dev/null +++ b/pkg/tools/hardware/serial_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package hardwaretools + +import "golang.org/x/sys/unix" + +func serialGetTermios(fd int) (*unix.Termios, error) { + return unix.IoctlGetTermios(fd, unix.TIOCGETA) +} + +func serialSetSpeed(tio *unix.Termios, speed uint32) error { + tio.Ispeed = uint64(speed) + tio.Ospeed = uint64(speed) + return nil +} + +func serialSetTermios(fd int, tio *unix.Termios) error { + return unix.IoctlSetTermios(fd, unix.TIOCSETA, tio) +} diff --git a/pkg/tools/hardware/serial_linux.go b/pkg/tools/hardware/serial_linux.go new file mode 100644 index 000000000..bad3e4cb8 --- /dev/null +++ b/pkg/tools/hardware/serial_linux.go @@ -0,0 +1,19 @@ +//go:build linux + +package hardwaretools + +import "golang.org/x/sys/unix" + +func serialGetTermios(fd int) (*unix.Termios, error) { + return unix.IoctlGetTermios(fd, unix.TCGETS) +} + +func serialSetSpeed(tio *unix.Termios, speed uint32) error { + tio.Ispeed = speed + tio.Ospeed = speed + return nil +} + +func serialSetTermios(fd int, tio *unix.Termios) error { + return unix.IoctlSetTermios(fd, unix.TCSETS, tio) +} diff --git a/pkg/tools/hardware/serial_other.go b/pkg/tools/hardware/serial_other.go new file mode 100644 index 000000000..ec72a2d2a --- /dev/null +++ b/pkg/tools/hardware/serial_other.go @@ -0,0 +1,21 @@ +//go:build !linux && !darwin && !windows + +package hardwaretools + +import ( + "context" + "fmt" + "time" +) + +func serialListPorts() ([]serialPortInfo, error) { + return nil, fmt.Errorf("serial is not supported on this platform") +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + return nil, fmt.Errorf("serial is not supported on this platform") +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + return 0, fmt.Errorf("serial is not supported on this platform") +} diff --git a/pkg/tools/hardware/serial_other_test.go b/pkg/tools/hardware/serial_other_test.go new file mode 100644 index 000000000..ef04c4062 --- /dev/null +++ b/pkg/tools/hardware/serial_other_test.go @@ -0,0 +1,18 @@ +//go:build !linux && !darwin && !windows + +package hardwaretools + +import ( + "strings" + "testing" +) + +func TestSerialListPortsUnsupportedPlatform(t *testing.T) { + _, err := serialListPorts() + if err == nil { + t.Fatal("expected unsupported platform error") + } + if !strings.Contains(err.Error(), "not supported") { + t.Fatalf("serialListPorts() error = %v, want unsupported platform message", err) + } +} diff --git a/pkg/tools/hardware/serial_test.go b/pkg/tools/hardware/serial_test.go new file mode 100644 index 000000000..6b2e9765d --- /dev/null +++ b/pkg/tools/hardware/serial_test.go @@ -0,0 +1,269 @@ +package hardwaretools + +import ( + "context" + "runtime" + "strings" + "testing" + "time" +) + +func TestParseSerialConfig(t *testing.T) { + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + cfg, errResult := parseSerialConfig(map[string]any{ + "port": port, + "baud": float64(9600), + "data_bits": float64(7), + "parity": "even", + "stop_bits": float64(2), + }) + if errResult != nil { + t.Fatalf("parseSerialConfig() unexpected error = %v", errResult.ForLLM) + } + + wantPort := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + wantPort = `\\.\COM3` + } + if cfg.Port != wantPort || cfg.Baud != 9600 || cfg.DataBits != 7 || cfg.Parity != "even" || cfg.StopBits != 2 { + t.Fatalf("parseSerialConfig() = %#v", cfg) + } +} + +func TestParseSerialConfigRejectsInvalidParity(t *testing.T) { + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, errResult := parseSerialConfig(map[string]any{ + "port": port, + "parity": "mark", + }) + if errResult == nil { + t.Fatal("expected invalid parity to fail") + } +} + +func TestParseSerialConfigRejectsUnsupportedUnixBaud(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("Unix baud validation only applies on Unix platforms") + } + + _, errResult := parseSerialConfig(map[string]any{ + "port": "/dev/ttyUSB0", + "baud": float64(460800), + }) + if errResult == nil { + t.Fatal("expected unsupported Unix baud rate to fail") + } +} + +func TestParseSerialWritePayloadRejectsFractionalBytes(t *testing.T) { + _, errResult := parseSerialWritePayload(map[string]any{ + "data": []any{65.9}, + }) + if errResult == nil { + t.Fatal("expected fractional byte value to fail") + } +} + +func TestValidateSerialBaud(t *testing.T) { + tests := []struct { + name string + baud int + wantErr bool + }{ + {name: "default-supported", baud: 115200}, + {name: "max-unix-supported", baud: 230400}, + {name: "too-low", baud: 49, wantErr: true}, + {name: "too-high", baud: 4000001, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSerialBaud(tt.baud) + if (err != nil) != tt.wantErr { + t.Fatalf("validateSerialBaud(%d) error = %v, wantErr %v", tt.baud, err, tt.wantErr) + } + }) + } +} + +func TestSerialReadCanceledBeforeOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, err := serialRead( + ctx, + serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1}, + 1, + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("serialRead() error = %v, want context canceled", err) + } +} + +func TestSerialWriteCanceledBeforeOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, err := serialWrite( + ctx, + serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1}, + []byte("AT"), + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("serialWrite() error = %v, want context canceled", err) + } +} + +func TestParseSerialConfigRejectsUnsafePortPaths(t *testing.T) { + tests := []string{ + "../../../etc/passwd", + "/etc/passwd", + `C:\temp\device.txt`, + `\\.\C:\temp\device.txt`, + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) { + _, errResult := parseSerialConfig(map[string]any{ + "port": port, + }) + if errResult == nil { + t.Fatalf("expected unsafe port %q to be rejected", port) + } + }) + } +} + +func TestNormalizeUnixSerialPath(t *testing.T) { + tests := []struct { + port string + want string + }{ + {port: "ttyUSB0", want: "/dev/ttyUSB0"}, + {port: "/dev/ttyACM0", want: "/dev/ttyACM0"}, + {port: "/dev/cu.usbserial-0001", want: "/dev/cu.usbserial-0001"}, + } + + for _, tt := range tests { + got, err := normalizeUnixSerialPath(tt.port) + if err != nil { + t.Fatalf("normalizeUnixSerialPath(%q) unexpected error = %v", tt.port, err) + } + if got != tt.want { + t.Fatalf("normalizeUnixSerialPath(%q) = %q, want %q", tt.port, got, tt.want) + } + } +} + +func TestNormalizeUnixSerialPathRejectsInvalidNames(t *testing.T) { + tests := []string{ + "", + "ttyUSB0/../../passwd", + "/dev/../../etc/passwd", + "/tmp/ttyUSB0", + "ttyUSB", + "COM3", + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) { + if _, err := normalizeUnixSerialPath(port); err == nil { + t.Fatalf("expected %q to be rejected", port) + } + }) + } +} + +func TestNormalizeWindowsSerialPath(t *testing.T) { + tests := []struct { + port string + want string + }{ + {port: "COM3", want: `\\.\COM3`}, + {port: "com12", want: `\\.\COM12`}, + {port: `\\.\COM7`, want: `\\.\COM7`}, + } + + for _, tt := range tests { + got, err := normalizeWindowsSerialPath(tt.port) + if err != nil { + t.Fatalf("normalizeWindowsSerialPath(%q) unexpected error = %v", tt.port, err) + } + if got != tt.want { + t.Fatalf("normalizeWindowsSerialPath(%q) = %q, want %q", tt.port, got, tt.want) + } + } +} + +func TestNormalizeWindowsSerialPathRejectsInvalidNames(t *testing.T) { + tests := []string{ + "", + "COM0", + "COM", + "/dev/ttyUSB0", + `C:\temp\device.txt`, + `\\.\C:\temp\device.txt`, + `\\server\share\COM3`, + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(strings.ReplaceAll(port, `\`, "_"), "/", "_"), func(t *testing.T) { + if _, err := normalizeWindowsSerialPath(port); err == nil { + t.Fatalf("expected %q to be rejected", port) + } + }) + } +} + +func TestParseSerialTimeout(t *testing.T) { + timeout, errResult := parseSerialTimeout(map[string]any{ + "timeout_ms": float64(2500), + }) + if errResult != nil { + t.Fatalf("parseSerialTimeout() unexpected error = %v", errResult.ForLLM) + } + if timeout != 2500*time.Millisecond { + t.Fatalf("timeout = %v, want 2500ms", timeout) + } +} + +func TestParseSerialWritePayloadSupportsText(t *testing.T) { + data, errResult := parseSerialWritePayload(map[string]any{ + "text": "AT\r\n", + }) + if errResult != nil { + t.Fatalf("parseSerialWritePayload() unexpected error = %v", errResult.ForLLM) + } + if string(data) != "AT\r\n" { + t.Fatalf("payload = %q, want %q", string(data), "AT\r\n") + } +} + +func TestParseSerialWritePayloadRejectsOutOfRangeByte(t *testing.T) { + _, errResult := parseSerialWritePayload(map[string]any{ + "data": []any{float64(256)}, + }) + if errResult == nil { + t.Fatal("expected payload validation failure") + } +} diff --git a/pkg/tools/hardware/serial_unix.go b/pkg/tools/hardware/serial_unix.go new file mode 100644 index 000000000..548b8573b --- /dev/null +++ b/pkg/tools/hardware/serial_unix.go @@ -0,0 +1,286 @@ +//go:build linux || darwin + +package hardwaretools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "golang.org/x/sys/unix" +) + +var ( + unixSerialNow = time.Now + unixSerialOpenPort = openAndConfigureSerialPort + unixSerialClosePort = unix.Close + unixSerialPollRead = pollRead + unixSerialPollWrite = pollWrite +) + +func serialListPorts() ([]serialPortInfo, error) { + patterns := []string{ + "/dev/ttyS*", + "/dev/ttyUSB*", + "/dev/ttyACM*", + "/dev/ttyAMA*", + "/dev/rfcomm*", + "/dev/tty.*", + "/dev/cu.*", + } + + seen := make(map[string]struct{}) + ports := make([]serialPortInfo, 0) + for _, pattern := range patterns { + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + for _, match := range matches { + if _, ok := seen[match]; ok { + continue + } + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + seen[match] = struct{}{} + ports = append(ports, serialPortInfo{ + Name: filepath.Base(match), + Path: match, + }) + } + } + + sort.Slice(ports, func(i, j int) bool { + return ports[i].Path < ports[j].Path + }) + return ports, nil +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + fd, err := unixSerialOpenPort(cfg) + if err != nil { + return nil, err + } + defer unixSerialClosePort(fd) + + buf := make([]byte, length) + total := 0 + deadline := unixSerialNow().Add(timeout) + + for total < length { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + remaining := deadline.Sub(unixSerialNow()) + if remaining <= 0 { + break + } + + n, err := unixSerialPollRead(fd, buf[total:], minSerialPollTimeout(remaining)) + if err != nil { + return nil, err + } + if n == 0 { + continue + } + total += n + } + + return buf[:total], nil +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + fd, err := unixSerialOpenPort(cfg) + if err != nil { + return 0, err + } + defer unixSerialClosePort(fd) + + total := 0 + deadline := unixSerialNow().Add(timeout) + for total < len(data) { + if err := serialContextErr(ctx); err != nil { + return total, err + } + + remaining := deadline.Sub(unixSerialNow()) + if remaining <= 0 { + return total, fmt.Errorf("timeout while writing serial data") + } + + n, err := unixSerialPollWrite(fd, data[total:], minSerialPollTimeout(remaining)) + if err != nil { + return total, err + } + if n == 0 { + continue + } + total += n + } + + return total, nil +} + +func openAndConfigureSerialPort(cfg serialConfig) (int, error) { + fd, err := unix.Open(cfg.Port, unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0) + if err != nil { + return -1, err + } + + if err := unix.SetNonblock(fd, false); err != nil { + unix.Close(fd) + return -1, err + } + + if err := configureUnixSerialPort(fd, cfg); err != nil { + unix.Close(fd) + return -1, err + } + + return fd, nil +} + +func configureUnixSerialPort(fd int, cfg serialConfig) error { + tio, err := serialGetTermios(fd) + if err != nil { + return err + } + + tio.Iflag = 0 + tio.Oflag = 0 + tio.Lflag = 0 + tio.Cflag = unix.CREAD | unix.CLOCAL + tio.Cc[unix.VMIN] = 0 + tio.Cc[unix.VTIME] = 0 + + switch cfg.DataBits { + case 5: + tio.Cflag |= unix.CS5 + case 6: + tio.Cflag |= unix.CS6 + case 7: + tio.Cflag |= unix.CS7 + default: + tio.Cflag |= unix.CS8 + } + + switch cfg.Parity { + case "even": + tio.Cflag |= unix.PARENB + case "odd": + tio.Cflag |= unix.PARENB | unix.PARODD + } + + if cfg.StopBits == 2 { + tio.Cflag |= unix.CSTOPB + } + + speed, err := serialBaudToUnix(cfg.Baud) + if err != nil { + return err + } + if err := serialSetSpeed(tio, speed); err != nil { + return err + } + + return serialSetTermios(fd, tio) +} + +func serialBaudToUnix(baud int) (uint32, error) { + switch baud { + case 50: + return unix.B50, nil + case 75: + return unix.B75, nil + case 110: + return unix.B110, nil + case 134: + return unix.B134, nil + case 150: + return unix.B150, nil + case 200: + return unix.B200, nil + case 300: + return unix.B300, nil + case 600: + return unix.B600, nil + case 1200: + return unix.B1200, nil + case 1800: + return unix.B1800, nil + case 2400: + return unix.B2400, nil + case 4800: + return unix.B4800, nil + case 9600: + return unix.B9600, nil + case 19200: + return unix.B19200, nil + case 38400: + return unix.B38400, nil + case 57600: + return unix.B57600, nil + case 115200: + return unix.B115200, nil + case 230400: + return unix.B230400, nil + default: + return 0, fmt.Errorf("unsupported baud rate on this platform: %d", baud) + } +} + +func pollRead(fd int, dst []byte, timeout time.Duration) (int, error) { + pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + n, err := unix.Poll(pfd, durationToPollTimeout(timeout)) + if err != nil { + return 0, err + } + if n == 0 { + return 0, nil + } + return unix.Read(fd, dst) +} + +func pollWrite(fd int, src []byte, timeout time.Duration) (int, error) { + pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}} + n, err := unix.Poll(pfd, durationToPollTimeout(timeout)) + if err != nil { + return 0, err + } + if n == 0 { + return 0, nil + } + return unix.Write(fd, src) +} + +func durationToPollTimeout(timeout time.Duration) int { + if timeout <= 0 { + return 0 + } + ms := int(timeout / time.Millisecond) + if ms == 0 { + return 1 + } + return ms +} + +func minSerialPollTimeout(timeout time.Duration) time.Duration { + if timeout > serialPollInterval { + return serialPollInterval + } + return timeout +} diff --git a/pkg/tools/hardware/serial_unix_test.go b/pkg/tools/hardware/serial_unix_test.go new file mode 100644 index 000000000..fac2efe7f --- /dev/null +++ b/pkg/tools/hardware/serial_unix_test.go @@ -0,0 +1,140 @@ +//go:build linux || darwin + +package hardwaretools + +import ( + "context" + "errors" + "testing" + "time" +) + +func stubUnixSerialIO(t *testing.T, now *time.Time) { + t.Helper() + + prevNow := unixSerialNow + prevOpen := unixSerialOpenPort + prevClose := unixSerialClosePort + prevPollRead := unixSerialPollRead + prevPollWrite := unixSerialPollWrite + + unixSerialNow = func() time.Time { + return *now + } + unixSerialOpenPort = func(cfg serialConfig) (int, error) { + return 42, nil + } + unixSerialClosePort = func(fd int) error { + return nil + } + unixSerialPollRead = prevPollRead + unixSerialPollWrite = prevPollWrite + + t.Cleanup(func() { + unixSerialNow = prevNow + unixSerialOpenPort = prevOpen + unixSerialClosePort = prevClose + unixSerialPollRead = prevPollRead + unixSerialPollWrite = prevPollWrite + }) +} + +func TestSerialReadWaitsPastEmptyPollsUntilDeadline(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + pollCalls := 0 + unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) { + pollCalls++ + if timeout > serialPollInterval { + t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval) + } + now = now.Add(timeout) + if pollCalls < 4 { + return 0, nil + } + return copy(dst, []byte("OK")), nil + } + + got, err := serialRead(context.Background(), serialConfig{}, 2, 500*time.Millisecond) + if err != nil { + t.Fatalf("serialRead() error = %v", err) + } + if string(got) != "OK" { + t.Fatalf("serialRead() = %q, want %q", got, "OK") + } + if pollCalls != 4 { + t.Fatalf("poll calls = %d, want 4", pollCalls) + } +} + +func TestSerialReadReturnsPromptlyOnContextCancelBetweenPolls(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + ctx, cancel := context.WithCancel(context.Background()) + pollCalls := 0 + unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) { + pollCalls++ + now = now.Add(timeout) + cancel() + return 0, nil + } + + _, err := serialRead(ctx, serialConfig{}, 1, time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("serialRead() error = %v, want context canceled", err) + } + if pollCalls != 1 { + t.Fatalf("poll calls = %d, want 1", pollCalls) + } +} + +func TestSerialWriteWaitsPastEmptyPollsUntilReady(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + pollCalls := 0 + unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) { + pollCalls++ + if timeout > serialPollInterval { + t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval) + } + now = now.Add(timeout) + switch pollCalls { + case 1, 2: + return 0, nil + default: + return 1, nil + } + } + + written, err := serialWrite(context.Background(), serialConfig{}, []byte("OK"), 500*time.Millisecond) + if err != nil { + t.Fatalf("serialWrite() error = %v", err) + } + if written != 2 { + t.Fatalf("serialWrite() wrote %d bytes, want 2", written) + } + if pollCalls != 4 { + t.Fatalf("poll calls = %d, want 4", pollCalls) + } +} + +func TestSerialWriteTimesOutAfterRepeatedEmptyPolls(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) { + now = now.Add(timeout) + return 0, nil + } + + written, err := serialWrite(context.Background(), serialConfig{}, []byte("A"), 250*time.Millisecond) + if err == nil || err.Error() != "timeout while writing serial data" { + t.Fatalf("serialWrite() error = %v, want timeout", err) + } + if written != 0 { + t.Fatalf("serialWrite() wrote %d bytes, want 0", written) + } +} diff --git a/pkg/tools/hardware/serial_windows.go b/pkg/tools/hardware/serial_windows.go new file mode 100644 index 000000000..31a215589 --- /dev/null +++ b/pkg/tools/hardware/serial_windows.go @@ -0,0 +1,247 @@ +//go:build windows + +package hardwaretools + +import ( + "context" + "sort" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetCommState = kernel32.NewProc("GetCommState") + procSetCommState = kernel32.NewProc("SetCommState") + procSetCommTimeouts = kernel32.NewProc("SetCommTimeouts") + procPurgeComm = kernel32.NewProc("PurgeComm") +) + +const ( + purgeTxClear = 0x0004 + purgeRxClear = 0x0008 + + dcbFlagBinary = 0x00000001 + dcbFlagParity = 0x00000002 + dcbFlagOutxCtsFlow = 0x00000004 + dcbFlagOutxDsrFlow = 0x00000008 + dcbFlagDtrControlMask = 0x00000030 + dcbFlagDsrSensitivity = 0x00000040 + dcbFlagTXContinueOnXoff = 0x00000080 + dcbFlagOutX = 0x00000100 + dcbFlagInX = 0x00000200 + dcbFlagRtsControlMask = 0x00003000 +) + +type dcb struct { + DCBlength uint32 + BaudRate uint32 + Flags uint32 + Reserved uint16 + XonLim uint16 + XoffLim uint16 + ByteSize byte + Parity byte + StopBits byte + XonChar byte + XoffChar byte + ErrorChar byte + EofChar byte + EvtChar byte + wReserved1 uint16 +} + +type commTimeouts struct { + ReadIntervalTimeout uint32 + ReadTotalTimeoutMultiplier uint32 + ReadTotalTimeoutConstant uint32 + WriteTotalTimeoutMultiplier uint32 + WriteTotalTimeoutConstant uint32 +} + +func serialListPorts() ([]serialPortInfo, error) { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE) + if err != nil { + if err == registry.ErrNotExist { + return nil, nil + } + return nil, err + } + defer key.Close() + + names, err := key.ReadValueNames(-1) + if err != nil { + return nil, err + } + + ports := make([]serialPortInfo, 0, len(names)) + seen := make(map[string]struct{}) + for _, name := range names { + value, _, err := key.GetStringValue(name) + if err != nil { + continue + } + portName := strings.TrimSpace(value) + if portName == "" { + continue + } + normalized := strings.ToUpper(portName) + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + ports = append(ports, serialPortInfo{ + Name: normalized, + Path: normalized, + }) + } + + sort.Slice(ports, func(i, j int) bool { + return ports[i].Path < ports[j].Path + }) + return ports, nil +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + handle, err := openAndConfigureWindowsSerial(cfg, timeout) + if err != nil { + return nil, err + } + defer windows.CloseHandle(handle) + + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + buf := make([]byte, length) + var read uint32 + // Synchronous serial I/O on Windows cannot be interrupted once the syscall starts. + // COMMTIMEOUTS bounds how long turn cancellation may take to surface. + if err := windows.ReadFile(handle, buf, &read, nil); err != nil { + return nil, err + } + return buf[:read], nil +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + handle, err := openAndConfigureWindowsSerial(cfg, timeout) + if err != nil { + return 0, err + } + defer windows.CloseHandle(handle) + + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + return serialWriteAll(ctx, data, timeout, time.Now, func(chunk []byte) (int, error) { + var written uint32 + // Like ReadFile above, this synchronous WriteFile call relies on COMMTIMEOUTS + // rather than context preemption once the syscall is in flight. + if err := windows.WriteFile(handle, chunk, &written, nil); err != nil { + return int(written), err + } + return int(written), nil + }) +} + +func openAndConfigureWindowsSerial(cfg serialConfig, timeout time.Duration) (windows.Handle, error) { + handle, err := windows.CreateFile( + windows.StringToUTF16Ptr(cfg.Port), + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, + nil, + windows.OPEN_EXISTING, + 0, + 0, + ) + if err != nil { + return 0, err + } + + if err := configureWindowsSerialPort(handle, cfg, timeout); err != nil { + windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +func configureWindowsSerialPort(handle windows.Handle, cfg serialConfig, timeout time.Duration) error { + state := dcb{DCBlength: uint32(unsafe.Sizeof(dcb{}))} + r1, _, err := procGetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state))) + if r1 == 0 { + return err + } + + state.BaudRate = uint32(cfg.Baud) + state.ByteSize = byte(cfg.DataBits) + state.Flags = sanitizeWindowsSerialFlags(state.Flags) + state.Flags |= dcbFlagBinary + + switch cfg.Parity { + case "even": + state.Parity = 2 + state.Flags |= dcbFlagParity + case "odd": + state.Parity = 1 + state.Flags |= dcbFlagParity + default: + state.Parity = 0 + state.Flags &^= dcbFlagParity + } + + switch cfg.StopBits { + case 2: + state.StopBits = 2 + default: + state.StopBits = 0 + } + + r1, _, err = procSetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state))) + if r1 == 0 { + return err + } + + timeoutMS := uint32(timeout / time.Millisecond) + if timeoutMS == 0 { + timeoutMS = 1 + } + timeouts := commTimeouts{ + ReadIntervalTimeout: timeoutMS, + ReadTotalTimeoutConstant: timeoutMS, + WriteTotalTimeoutConstant: timeoutMS, + ReadTotalTimeoutMultiplier: 0, + WriteTotalTimeoutMultiplier: 0, + } + r1, _, err = procSetCommTimeouts.Call(uintptr(handle), uintptr(unsafe.Pointer(&timeouts))) + if r1 == 0 { + return err + } + + procPurgeComm.Call(uintptr(handle), uintptr(purgeRxClear|purgeTxClear)) + return nil +} + +func sanitizeWindowsSerialFlags(flags uint32) uint32 { + flags &^= dcbFlagOutxCtsFlow | + dcbFlagOutxDsrFlow | + dcbFlagDtrControlMask | + dcbFlagDsrSensitivity | + dcbFlagTXContinueOnXoff | + dcbFlagOutX | + dcbFlagInX | + dcbFlagRtsControlMask + return flags +} diff --git a/pkg/tools/hardware/serial_windows_test.go b/pkg/tools/hardware/serial_windows_test.go new file mode 100644 index 000000000..ecb0addbd --- /dev/null +++ b/pkg/tools/hardware/serial_windows_test.go @@ -0,0 +1,39 @@ +//go:build windows + +package hardwaretools + +import "testing" + +func TestSanitizeWindowsSerialFlags(t *testing.T) { + flags := uint32( + dcbFlagBinary | + dcbFlagParity | + dcbFlagOutxCtsFlow | + dcbFlagOutxDsrFlow | + dcbFlagDtrControlMask | + dcbFlagDsrSensitivity | + dcbFlagTXContinueOnXoff | + dcbFlagOutX | + dcbFlagInX | + dcbFlagRtsControlMask, + ) + + got := sanitizeWindowsSerialFlags(flags) + + if got&dcbFlagBinary == 0 { + t.Fatal("sanitizeWindowsSerialFlags() should preserve fBinary") + } + if got&dcbFlagParity == 0 { + t.Fatal("sanitizeWindowsSerialFlags() should preserve fParity") + } + if got&(dcbFlagOutxCtsFlow| + dcbFlagOutxDsrFlow| + dcbFlagDtrControlMask| + dcbFlagDsrSensitivity| + dcbFlagTXContinueOnXoff| + dcbFlagOutX| + dcbFlagInX| + dcbFlagRtsControlMask) != 0 { + t.Fatalf("sanitizeWindowsSerialFlags() = %#x, want flow-control bits cleared", got) + } +} diff --git a/pkg/tools/hardware/serial_write_common_test.go b/pkg/tools/hardware/serial_write_common_test.go new file mode 100644 index 000000000..398c1fde5 --- /dev/null +++ b/pkg/tools/hardware/serial_write_common_test.go @@ -0,0 +1,87 @@ +package hardwaretools + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSerialWriteAllRetriesPartialWritesUntilComplete(t *testing.T) { + now := time.Unix(0, 0) + calls := 0 + + written, err := serialWriteAll(context.Background(), []byte("PING"), time.Second, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + switch calls { + case 1: + if string(chunk) != "PING" { + t.Fatalf("first chunk = %q, want %q", chunk, "PING") + } + return 2, nil + case 2: + if string(chunk) != "NG" { + t.Fatalf("second chunk = %q, want %q", chunk, "NG") + } + return 2, nil + default: + t.Fatalf("unexpected extra write call %d", calls) + return 0, nil + } + }) + if err != nil { + t.Fatalf("serialWriteAll() error = %v", err) + } + if written != 4 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 4", written) + } +} + +func TestSerialWriteAllTimesOutAfterZeroByteWrites(t *testing.T) { + now := time.Unix(0, 0) + calls := 0 + + written, err := serialWriteAll(context.Background(), []byte("A"), 250*time.Millisecond, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + return 0, nil + }) + if err == nil || err.Error() != "timeout while writing serial data" { + t.Fatalf("serialWriteAll() error = %v, want timeout", err) + } + if written != 0 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written) + } + if calls != 3 { + t.Fatalf("write calls = %d, want 3", calls) + } +} + +func TestSerialWriteAllReturnsContextCancellationAfterRetryBoundary(t *testing.T) { + now := time.Unix(0, 0) + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + + written, err := serialWriteAll(ctx, []byte("A"), time.Second, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + cancel() + return 0, nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("serialWriteAll() error = %v, want context canceled", err) + } + if written != 0 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written) + } + if calls != 1 { + t.Fatalf("write calls = %d, want 1", calls) + } +} diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go index f55d152cf..b505c5a48 100644 --- a/pkg/tools/hardware_facade.go +++ b/pkg/tools/hardware_facade.go @@ -3,8 +3,9 @@ package tools import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware" type ( - I2CTool = hardwaretools.I2CTool - SPITool = hardwaretools.SPITool + I2CTool = hardwaretools.I2CTool + SerialTool = hardwaretools.SerialTool + SPITool = hardwaretools.SPITool ) func NewI2CTool() *I2CTool { @@ -14,3 +15,7 @@ func NewI2CTool() *I2CTool { func NewSPITool() *SPITool { return hardwaretools.NewSPITool() } + +func NewSerialTool() *SerialTool { + return hardwaretools.NewSerialTool() +} diff --git a/pkg/tools/integration/mcp_tool.go b/pkg/tools/integration/mcp_tool.go index 78c348316..8cfc1de5e 100644 --- a/pkg/tools/integration/mcp_tool.go +++ b/pkg/tools/integration/mcp_tool.go @@ -13,6 +13,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" @@ -36,6 +37,16 @@ type MCPTool struct { mediaStore media.MediaStore workspace string maxInlineTextRunes int + runtimeEvents runtimeevents.Bus +} + +// MCPToolCallPayload describes MCP tool execution runtime events. +type MCPToolCallPayload struct { + Server string `json:"server"` + Tool string `json:"tool"` + DurationMS int64 `json:"duration_ms,omitempty"` + IsError bool `json:"is_error,omitempty"` + Error string `json:"error,omitempty"` } // NewMCPTool creates a new MCP tool wrapper @@ -62,6 +73,11 @@ func (t *MCPTool) SetMaxInlineTextRunes(limit int) { } } +// SetEventPublisher injects the runtime event bus used for MCP tool observations. +func (t *MCPTool) SetEventPublisher(eventBus runtimeevents.Bus) { + t.runtimeEvents = eventBus +} + const maxMCPInlineTextRunes = 16 * 1024 // sanitizeIdentifierComponent normalizes a string so it can be safely used @@ -237,26 +253,88 @@ func (t *MCPTool) Parameters() map[string]any { // Execute executes the MCP tool func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + startedAt := time.Now() + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallStart, startedAt, false, "") + result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args) if err != nil { + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, err.Error()) return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) } if result == nil { nilErr := fmt.Errorf("MCP tool returned nil result without error") + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, nilErr.Error()) return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr) } // Handle error result from server if result.IsError { errMsg := extractContentText(result.Content) + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, errMsg) return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)). WithError(fmt.Errorf("MCP tool error: %s", errMsg)) } + t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, false, "") return t.normalizeResultContent(ctx, result.Content) } +func (t *MCPTool) publishRuntimeEvent( + ctx context.Context, + kind runtimeevents.Kind, + startedAt time.Time, + isError bool, + errMsg string, +) { + if t == nil || t.runtimeEvents == nil { + return + } + + scope := runtimeevents.Scope{ + AgentID: toolshared.ToolAgentID(ctx), + SessionKey: toolshared.ToolSessionKey(ctx), + Channel: toolshared.ToolChannel(ctx), + ChatID: toolshared.ToolChatID(ctx), + MessageID: toolshared.ToolMessageID(ctx), + } + payload := MCPToolCallPayload{ + Server: t.serverName, + Tool: t.tool.Name, + DurationMS: time.Since(startedAt).Milliseconds(), + IsError: isError, + Error: errMsg, + } + severity := runtimeevents.SeverityInfo + if isError { + severity = runtimeevents.SeverityError + } + + t.runtimeEvents.PublishNonBlocking(runtimeevents.Event{ + Kind: kind, + Source: runtimeevents.Source{Component: "mcp", Name: t.serverName}, + Scope: scope, + Severity: severity, + Payload: payload, + Attrs: mcpToolCallEventAttrs(payload), + }) +} + +func mcpToolCallEventAttrs(payload MCPToolCallPayload) map[string]any { + attrs := map[string]any{ + "server": payload.Server, + "tool": payload.Tool, + "duration_ms": payload.DurationMS, + } + if payload.IsError { + attrs["is_error"] = payload.IsError + } + if payload.Error != "" { + attrs["error"] = payload.Error + } + return attrs +} + // extractContentText extracts text from MCP content array func extractContentText(content []mcp.Content) string { var parts []string diff --git a/pkg/tools/integration/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go index 7b0b2cd5a..7c961e1e1 100644 --- a/pkg/tools/integration/mcp_tool_test.go +++ b/pkg/tools/integration/mcp_tool_test.go @@ -7,9 +7,11 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" + runtimeevents "github.com/sipeed/picoclaw/pkg/events" "github.com/sipeed/picoclaw/pkg/media" toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" ) @@ -299,6 +301,77 @@ func TestMCPTool_Execute_Success(t *testing.T) { } } +func TestMCPTool_Execute_PublishesRuntimeEvents(t *testing.T) { + eventBus := runtimeevents.NewBus() + defer func() { + if err := eventBus.Close(); err != nil { + t.Errorf("event bus close failed: %v", err) + } + }() + + _, eventsCh, err := eventBus.Channel().OfKind( + runtimeevents.KindMCPToolCallStart, + runtimeevents.KindMCPToolCallEnd, + ).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-tool-events", Buffer: 2}) + if err != nil { + t.Fatalf("SubscribeChan failed: %v", err) + } + + manager := &MockMCPManager{} + mcpTool := NewMCPTool(manager, "github", &mcp.Tool{Name: "search_repos"}) + mcpTool.SetEventPublisher(eventBus) + + ctx := toolshared.WithToolContext(context.Background(), "telegram", "chat-1") + ctx = toolshared.WithToolMessageContext(ctx, "msg-1", "") + ctx = toolshared.WithToolSessionContext(ctx, "main", "session-1", nil) + result := mcpTool.Execute(ctx, map[string]any{"query": "picoclaw"}) + if result == nil || result.IsError { + t.Fatalf("Execute result = %+v", result) + } + + started := receiveMCPToolRuntimeEvent(t, eventsCh) + if started.Kind != runtimeevents.KindMCPToolCallStart || + started.Scope.AgentID != "main" || + started.Scope.SessionKey != "session-1" || + started.Scope.Channel != "telegram" || + started.Scope.ChatID != "chat-1" || + started.Scope.MessageID != "msg-1" { + t.Fatalf("started event = %+v", started) + } + + ended := receiveMCPToolRuntimeEvent(t, eventsCh) + if ended.Kind != runtimeevents.KindMCPToolCallEnd || ended.Severity != runtimeevents.SeverityInfo { + t.Fatalf("ended event = %+v", ended) + } + payload, ok := ended.Payload.(MCPToolCallPayload) + if !ok { + t.Fatalf("ended payload = %T, want MCPToolCallPayload", ended.Payload) + } + if payload.Server != "github" || payload.Tool != "search_repos" || payload.IsError { + t.Fatalf("ended payload = %+v", payload) + } + if ended.Attrs["server"] != "github" || + ended.Attrs["tool"] != "search_repos" || + ended.Attrs["duration_ms"] == nil { + t.Fatalf("ended attrs = %#v", ended.Attrs) + } +} + +func receiveMCPToolRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event { + t.Helper() + + select { + case evt, ok := <-ch: + if !ok { + t.Fatal("runtime event channel closed before expected event") + } + return evt + case <-time.After(time.Second): + t.Fatal("timed out waiting for runtime event") + return runtimeevents.Event{} + } +} + // TestMCPTool_Execute_ManagerError tests execution when manager returns error func TestMCPTool_Execute_ManagerError(t *testing.T) { manager := &MockMCPManager{ diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index ada89efb7..feeabe536 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -30,6 +30,7 @@ type SubTurnConfig struct { ActualSystemPrompt string InitialMessages []providers.Message InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget + TargetAgentID string // If set, run as this agent (its workspace, model, tools) } type SubagentTask struct { diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index de7cb467e..1834d7f78 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -1,12 +1,35 @@ package utils import ( + "bytes" + "encoding/json" "fmt" "strings" ) const ToolFeedbackContinuationHint = "Continuing the current task." +func FormatArgsJSON(args map[string]any, prettyPrint, disableEscapeHTML bool) string { + // Normalize nil to empty map for consistent output + if args == nil { + args = map[string]any{} + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + if prettyPrint { + enc.SetIndent("", " ") + } + if disableEscapeHTML { + enc.SetEscapeHTML(false) + } + if err := enc.Encode(args); err != nil { + // Fallback to fmt.Sprintf to preserve visibility of problematic args + return fmt.Sprintf("%v", args) + } + return strings.TrimSpace(buf.String()) +} + // FormatToolFeedbackMessage renders a tool feedback message for chat channels. // It keeps the tool name on the first line for animation and can include both // a human explanation and the serialized tool arguments in the body. diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index c30f53827..da4accce4 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -1,6 +1,9 @@ package utils -import "testing" +import ( + "encoding/json" + "testing" +) func TestFormatToolFeedbackMessage(t *testing.T) { got := FormatToolFeedbackMessage( @@ -56,3 +59,98 @@ func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) { t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) } } + +func TestFormatArgsJSON_Defaults(t *testing.T) { + args := map[string]any{"path": "README.md", "line": 42} + got := FormatArgsJSON(args, false, false) + var gotVal, wantVal any + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + want := `{"path":"README.md","line":42}` + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_PrettyPrint(t *testing.T) { + args := map[string]any{"path": "README.md", "line": 42} + got := FormatArgsJSON(args, true, false) + var gotVal any + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + want := `{"path":"README.md","line":42}` + var wantVal any + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() prettyPrint = %q, want structure %q", got, want) + } +} + +func TestFormatArgsJSON_DisableEscapeHTML(t *testing.T) { + args := map[string]any{"msg": "a < b && c > d"} + got := FormatArgsJSON(args, false, true) + var gotVal, wantVal any + want := `{"msg":"a < b && c > d"}` + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() disableEscapeHTML = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_PrettyPrintAndDisableEscapeHTML(t *testing.T) { + args := map[string]any{"msg": "a < b && c > d"} + got := FormatArgsJSON(args, true, true) + var gotVal, wantVal any + want := `{"msg":"a < b && c > d"}` + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() combined = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_EscapeHTMLByDefault(t *testing.T) { + args := map[string]any{"msg": "a < b && c > d"} + got := FormatArgsJSON(args, false, false) + var gotVal, wantVal any + want := `{"msg":"a \u003c b \u0026\u0026 c \u003e d"}` + if err := json.Unmarshal([]byte(got), &gotVal); err != nil { + t.Fatalf("FormatArgsJSON() returned invalid JSON: %v", err) + } + if err := json.Unmarshal([]byte(want), &wantVal); err != nil { + t.Fatalf("invalid test want JSON: %v", err) + } + if !jsonValEq(gotVal, wantVal) { + t.Fatalf("FormatArgsJSON() default escape = %q, want %q", got, want) + } +} + +func TestFormatArgsJSON_NilArgs(t *testing.T) { + got := FormatArgsJSON(nil, false, false) + want := `{}` + if got != want { + t.Fatalf("FormatArgsJSON() nil = %q, want %q", got, want) + } +} + +func jsonValEq(a, b any) bool { + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + return string(aJSON) == string(bJSON) +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 67b055236..45f7e6912 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { if modelCfg == nil { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } + if !defaultModelAllowedForModelConfig(modelCfg) { + return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil + } if !hasModelConfiguration(modelCfg) { return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 1d9352972..f383089a6 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -357,6 +357,44 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) { } } +func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatal("gatewayStartReady() ready = true, want false") + } + if reason != `default model "elevenlabs-asr" is not usable for chat` { + t.Fatalf( + "gatewayStartReady() reason = %q, want %q", + reason, + `default model "elevenlabs-asr" is not usable for chat`, + ) + } +} + func TestLooksLikeGatewayCommandLine(t *testing.T) { cases := []struct { name string diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index d262cf124..302231d80 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "os/exec" "strconv" "strings" "sync" @@ -47,6 +48,7 @@ var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel + probeCommandAvailableFunc = probeCommandAvailable modelProbeNowFunc = time.Now modelProbeState = newModelProbeCacheState() ) @@ -83,17 +85,23 @@ func (s *modelProbeCacheState) resetForTest() { } func hasModelConfiguration(m *config.ModelConfig) bool { + protocol := modelProtocol(m) authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) apiKey := strings.TrimSpace(m.APIKey()) if authMethod == "oauth" || authMethod == "token" { - if provider, ok := oauthProviderForModel(m); ok { - cred, err := oauthGetCredential(provider) - if err != nil || cred == nil { - return false - } - return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured } + } + + if authMethod == "" && providerUsesImplicitOAuth(protocol) { + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured + } + } + + if providerUsesAmbientCredentials(protocol) { return true } @@ -104,6 +112,40 @@ func hasModelConfiguration(m *config.ModelConfig) bool { return apiKey != "" } +func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) { + provider, ok := oauthProviderForModel(m) + if !ok { + return false, false + } + cred, err := oauthGetCredential(provider) + if err != nil || cred == nil { + return false, true + } + return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true +} + +func providerUsesImplicitOAuth(protocol string) bool { + switch protocol { + case "antigravity", "google-antigravity": + return true + default: + return false + } +} + +func providerUsesAmbientCredentials(protocol string) bool { + switch protocol { + case "bedrock": + // Bedrock relies on the AWS SDK credential chain instead of an explicit + // API key stored in ModelConfig. We cannot reliably preflight every AWS + // credential source here, so avoid misclassifying valid environments as + // "unconfigured" and defer concrete credential failures to runtime. + return true + default: + return false + } +} + func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary { if !hasModelConfiguration(m) { return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured} @@ -180,8 +222,10 @@ func runLocalModelProbe(m *config.ModelConfig) bool { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) - case "claude-cli", "claudecli", "codex-cli", "codexcli": - return true + case "claude-cli", "claudecli": + return probeCommandAvailableFunc("claude") + case "codex-cli", "codexcli": + return probeCommandAvailableFunc("codex") default: if hasLocalAPIBase(apiBase) { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) @@ -190,6 +234,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool { } } +func probeCommandAvailable(command string) bool { + _, err := exec.LookPath(command) + return err == nil +} + func modelProbeCacheKey(m *config.ModelConfig) string { protocol, modelID := splitModel(m) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index cf903ce4c..8a66918f9 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -35,20 +36,194 @@ type modelResponse struct { Proxy string `json:"proxy,omitempty"` AuthMethod string `json:"auth_method,omitempty"` // Advanced fields - ConnectMode string `json:"connect_mode,omitempty"` - Workspace string `json:"workspace,omitempty"` - RPM int `json:"rpm,omitempty"` - MaxTokensField string `json:"max_tokens_field,omitempty"` - RequestTimeout int `json:"request_timeout,omitempty"` - ThinkingLevel string `json:"thinking_level,omitempty"` - ExtraBody map[string]any `json:"extra_body,omitempty"` - CustomHeaders map[string]string `json:"custom_headers,omitempty"` + ConnectMode string `json:"connect_mode,omitempty"` + Workspace string `json:"workspace,omitempty"` + RPM int `json:"rpm,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + ToolSchemaTransform string `json:"tool_schema_transform,omitempty"` + ExtraBody map[string]any `json:"extra_body,omitempty"` + CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Meta - Enabled bool `json:"enabled"` - Available bool `json:"available"` - Status string `json:"status"` - IsDefault bool `json:"is_default"` - IsVirtual bool `json:"is_virtual"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Status string `json:"status"` + IsDefault bool `json:"is_default"` + IsVirtual bool `json:"is_virtual"` + DefaultModelAllowed bool `json:"default_model_allowed"` +} + +func normalizeStoredModelConfig(mc *config.ModelConfig) bool { + if mc == nil { + return false + } + + changed := false + model := strings.TrimSpace(mc.Model) + if model != mc.Model { + mc.Model = model + changed = true + } + provider := strings.TrimSpace(mc.Provider) + if provider != mc.Provider { + mc.Provider = provider + changed = true + } + authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if authMethod != mc.AuthMethod { + mc.AuthMethod = authMethod + changed = true + } + + if provider != "" { + normalizedProvider := providers.NormalizeProvider(provider) + if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider { + mc.Provider = normalizedProvider + changed = true + } + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut( + model, + "/", + ); found && + providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" && strippedModel != mc.Model { + mc.Model = strippedModel + changed = true + } + } + if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + mc.Model = asr.ElevenLabsSupportedModelID() + changed = true + } + } + return changed + } + + effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai") + if effectiveProvider == "" { + return changed + } + if mc.Provider != effectiveProvider { + mc.Provider = effectiveProvider + changed = true + } + if mc.Model != modelID { + mc.Model = modelID + changed = true + } + return changed +} + +func normalizeIncomingModelConfig(mc *config.ModelConfig) { + if mc == nil { + return + } + + mc.Model = strings.TrimSpace(mc.Model) + mc.Provider = strings.TrimSpace(mc.Provider) + mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if mc.Provider == "" { + mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai") + } else { + mc.Provider = providers.NormalizeProvider(mc.Provider) + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut(mc.Model, "/"); found { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" { + mc.Model = strippedModel + } + } + } + } + if mc.Provider == "antigravity" && mc.AuthMethod == "" { + mc.AuthMethod = "oauth" + } +} + +func createAllowedForProvider(provider string) bool { + normalized := providers.NormalizeProvider(provider) + switch normalized { + case "bedrock": + // Bedrock currently authenticates through the AWS SDK credential chain + // (env vars, shared profiles, IAM roles, etc.), and this Web layer does + // not yet have a reliable preflight check for those credential sources. + // Keep it creatable in the catalog and let provider construction/runtime + // return the concrete AWS error when the environment is incomplete. + return true + case "claude-cli", "codex-cli": + return cliProviderCreateAllowedFromCurrentStatus(normalized) + default: + return providers.IsCreatableModelProvider(normalized) + } +} + +// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing +// local model status pipeline so provider catalog gating follows the same CLI +// executable probe used by launcher readiness. +func cliProviderCreateAllowedFromCurrentStatus(provider string) bool { + status := modelConfigurationStatus(&config.ModelConfig{ + Provider: provider, + Model: provider, + }) + return status.Available +} + +func modelProviderOptionsForResponse() []providers.ModelProviderOption { + options := providers.ModelProviderOptions() + for i := range options { + options[i].CreateAllowed = createAllowedForProvider(options[i].ID) + } + return options +} + +func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool { + provider, _ := providers.ExtractProtocol(mc) + return providers.IsDefaultModelProvider(provider) +} + +func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error { + if mc == nil { + return fmt.Errorf("model config is required") + } + if err := mc.Validate(); err != nil { + return err + } + if strings.TrimSpace(mc.Provider) == "" { + return fmt.Errorf("provider is required") + } + if !providers.IsSupportedModelProvider(mc.Provider) { + return fmt.Errorf("provider %q is not supported", mc.Provider) + } + if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID()) + } + if !createAllowedForProvider(mc.Provider) { + if existing == nil { + return fmt.Errorf("provider %q is not available for new models", mc.Provider) + } + existingProvider, _ := providers.ExtractProtocol(existing) + if providers.NormalizeProvider(existingProvider) != mc.Provider { + return fmt.Errorf("provider %q is not available for selection", mc.Provider) + } + } + return nil +} + +func normalizeStoredModelProviders(cfg *config.Config) bool { + if cfg == nil { + return false + } + + changed := false + for _, model := range cfg.ModelList { + if normalizeStoredModelConfig(model) { + changed = true + } + } + return changed } // handleListModels returns all model_list entries with masked API keys. @@ -61,6 +236,10 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { return } + // Normalize legacy provider/model storage in memory so GET can round-trip + // through the current API shape without mutating the on-disk config. + normalizeStoredModelProviders(cfg) + defaultModel := cfg.Agents.Defaults.GetModelName() modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList)) @@ -78,35 +257,38 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { for i, m := range cfg.ModelList { provider, modelID := providers.ExtractProtocol(m) models = append(models, modelResponse{ - Index: i, - ModelName: m.ModelName, - Provider: provider, - Model: modelID, - APIBase: m.APIBase, - APIKey: maskAPIKey(m.APIKey()), - Proxy: m.Proxy, - AuthMethod: m.AuthMethod, - ConnectMode: m.ConnectMode, - Workspace: m.Workspace, - RPM: m.RPM, - MaxTokensField: m.MaxTokensField, - RequestTimeout: m.RequestTimeout, - ThinkingLevel: m.ThinkingLevel, - ExtraBody: m.ExtraBody, - CustomHeaders: m.CustomHeaders, - Enabled: m.Enabled, - Available: modelStatuses[i].Available, - Status: modelStatuses[i].Status, - IsDefault: m.ModelName == defaultModel, - IsVirtual: m.IsVirtual(), + Index: i, + ModelName: m.ModelName, + Provider: provider, + Model: modelID, + APIBase: m.APIBase, + APIKey: maskAPIKey(m.APIKey()), + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + ToolSchemaTransform: m.ToolSchemaTransform, + ExtraBody: m.ExtraBody, + CustomHeaders: m.CustomHeaders, + Enabled: m.Enabled, + Available: modelStatuses[i].Available, + Status: modelStatuses[i].Status, + IsDefault: m.ModelName == defaultModel, + IsVirtual: m.IsVirtual(), + DefaultModelAllowed: defaultModelAllowedForModelConfig(m), }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "models": models, - "total": len(models), - "default_model": defaultModel, + "models": models, + "total": len(models), + "default_model": defaultModel, + "provider_options": modelProviderOptionsForResponse(), }) } @@ -132,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { + normalizeIncomingModelConfig(&mc.ModelConfig) + + if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil { http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) return } @@ -148,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { } cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig) + normalizeStoredModelProviders(cfg) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) @@ -198,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { - http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) - return - } - cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) @@ -237,6 +417,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } else if len(mc.CustomHeaders) == 0 { mc.CustomHeaders = nil } + if _, ok := rawFields["tool_schema_transform"]; !ok { + mc.ToolSchemaTransform = cfg.ModelList[idx].ToolSchemaTransform + } // Preserve the existing Provider when the caller omits it. This keeps the // update API backward-compatible for clients that haven't started sending // the new field yet, while still allowing explicit clearing via "". @@ -248,9 +431,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { // This keeps provider-omitted updates backward-compatible even when an // older client edits the visible model ID. if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { - existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) incomingModel := strings.TrimSpace(mc.Model) + existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { if incomingModel == existingModelID { mc.Model = existingRawModel @@ -267,7 +450,20 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } } + normalizeIncomingModelConfig(&mc.ModelConfig) + if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName && + !defaultModelAllowedForModelConfig(&mc.ModelConfig) { + // Allow users to recover from legacy/invalid defaults by saving the model + // and clearing the default chat model reference in the same write. + cfg.Agents.Defaults.ModelName = "" + } + cfg.ModelList[idx] = &mc.ModelConfig + normalizeStoredModelProviders(cfg) logger.Debugf("update model config: %#v", mc.ModelConfig) @@ -367,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest) return } + for _, m := range cfg.ModelList { + if m.ModelName == req.ModelName { + if !defaultModelAllowedForModelConfig(m) { + http.Error( + w, + fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName), + http.StatusBadRequest, + ) + return + } + break + } + } cfg.Agents.Defaults.ModelName = req.ModelName diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index f374ac15b..0b1f04848 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) func resetModelProbeHooks(t *testing.T) { @@ -20,17 +21,46 @@ func resetModelProbeHooks(t *testing.T) { origTCPProbe := probeTCPServiceFunc origOllamaProbe := probeOllamaModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc + origCommandProbe := probeCommandAvailableFunc origNow := modelProbeNowFunc resetModelProbeCache() t.Cleanup(func() { probeTCPServiceFunc = origTCPProbe probeOllamaModelFunc = origOllamaProbe probeOpenAICompatibleModelFunc = origOpenAIProbe + probeCommandAvailableFunc = origCommandProbe modelProbeNowFunc = origNow resetModelProbeCache() }) } +func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig { + t.Helper() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(cfg.ModelList) == 0 { + t.Fatal("model_list should contain the newly added model") + } + + return cfg.ModelList[len(cfg.ModelList)-1] +} + func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -94,7 +124,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing }, } cfg.Agents.Defaults.ModelName = "openai-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -113,7 +144,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } @@ -181,14 +213,91 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "claude-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } - if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ AccessToken: "anthropic-token", Provider: oauthProviderAnthropic, AuthMethod: "oauth", + }); setCredentialErr != nil { + t.Fatalf("SetCredential() error = %v", setCredentialErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatalf("oauth model available = false, want true with stored credential") + } +} + +func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) { + noKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + } + if hasModelConfiguration(noKey) { + t.Fatal("oauth model without credential mapping and api key should be unconfigured") + } + + withKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + APIKeys: config.SimpleSecureStrings("gemini-key"), + } + if !hasModelConfiguration(withKey) { + t.Fatal("oauth model without credential mapping should fall back to api key configuration") + } +} + +func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "gemini-flash", + Provider: "antigravity", + Model: "gemini-3-flash", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{ + AccessToken: "antigravity-token", + Provider: oauthProviderGoogleAntigravity, + AuthMethod: "oauth", }); err != nil { t.Fatalf("SetCredential() error = %v", err) } @@ -208,14 +317,158 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { - t.Fatalf("oauth model available = false, want true with stored credential") + t.Fatal("antigravity model available = false, want true with stored credential even without auth_method") + } +} + +func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials") + } + if resp.Models[0].Status != modelStatusAvailable { + t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable) + } +} + +func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + switch command { + case "claude": + return false + case "codex": + return true + default: + return false + } + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "claude-cli-model", + Provider: "claude-cli", + Model: "claude-cli", + }, + { + ModelName: "codex-cli-model", + Provider: "codex-cli", + Model: "codex-cli", + }, + } + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + + modelsByName := make(map[string]modelResponse, len(resp.Models)) + for _, model := range resp.Models { + modelsByName[model.ModelName] = model + } + if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable { + t.Fatalf( + "claude-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + false, + modelStatusUnreachable, + ) + } + if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable { + t.Fatalf( + "codex-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + true, + modelStatusAvailable, + ) + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if option, ok := optionsByID["claude-cli"]; !ok { + t.Fatal("claude-cli provider option missing") + } else if option.CreateAllowed { + t.Fatal("claude-cli should not be creatable when the claude command is missing") + } + if option, ok := optionsByID["codex-cli"]; !ok { + t.Fatal("codex-cli provider option missing") + } else if !option.CreateAllowed { + t.Fatal("codex-cli should be creatable when the codex command is available") } } @@ -321,8 +574,8 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) @@ -508,6 +761,223 @@ func TestHandleAddModel_PersistsProvider(t *testing.T) { } } +func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bad-provider", + "provider":"not-supported", + "model":"gpt-4o-mini" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) { + t.Fatalf("body = %q, want unsupported provider error", rec.Body.String()) + } +} + +func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-sonnet-4-20250514-v1:0" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want bedrock model ID", got) + } +} + +func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(updated.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + return false + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"claude-cli-model", + "provider":"claude-cli", + "model":"claude-cli" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) { + t.Fatalf("body = %q, want missing cli command error", rec.Body.String()) + } +} + +func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"gemini-flash", + "provider":"antigravity", + "model":"gemini-3-flash" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + +func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"openai-oauth", + "provider":"openai", + "model":"gpt-5.4", + "auth_method":"OAuth" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -584,6 +1054,37 @@ func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) { } } +func TestHandleAddModel_PersistsToolSchemaTransform(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model-transform", + "model":"openai/gpt-4o-mini", + "tool_schema_transform":"simple" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.ToolSchemaTransform; got != "simple" { + t.Fatalf("tool_schema_transform = %q, want %q", got, "simple") + } +} + func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -649,6 +1150,69 @@ func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) { } } +func TestHandleUpdateModel_ToolSchemaTransformPreserveAndClear(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "editable", + Model: "openai/gpt-4o-mini", + APIKeys: config.SimpleSecureStrings("sk-existing"), + ToolSchemaTransform: "simple", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recPreserve := httptest.NewRecorder() + reqPreserve := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "model":"openai/gpt-4o-mini" + }`)) + reqPreserve.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recPreserve, reqPreserve) + if recPreserve.Code != http.StatusOK { + t.Fatalf("preserve status = %d, want %d, body=%s", recPreserve.Code, http.StatusOK, recPreserve.Body.String()) + } + + afterPreserve, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() after preserve error = %v", err) + } + if got := afterPreserve.ModelList[0].ToolSchemaTransform; got != "simple" { + t.Fatalf("preserved tool_schema_transform = %q, want %q", got, "simple") + } + + recClear := httptest.NewRecorder() + reqClear := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"editable", + "model":"openai/gpt-4o-mini", + "tool_schema_transform":"" + }`)) + reqClear.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recClear, reqClear) + if recClear.Code != http.StatusOK { + t.Fatalf("clear status = %d, want %d, body=%s", recClear.Code, http.StatusOK, recClear.Body.String()) + } + + afterClear, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() after clear error = %v", err) + } + if afterClear.ModelList[0].ToolSchemaTransform != "" { + t.Fatalf("tool_schema_transform = %q, want empty", afterClear.ModelList[0].ToolSchemaTransform) + } +} + func TestHandleUpdateModel_PersistsProvider(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -751,7 +1315,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { Provider: "openrouter", Model: "openrouter/auto", }} - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -770,7 +1335,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { @@ -784,6 +1350,55 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { } } +func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := resp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if resp.Models[0].DefaultModelAllowed { + t.Fatal("elevenlabs ASR model should not be allowed as the default chat model") + } +} + func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -846,11 +1461,230 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } +} + +func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.Agents.Defaults.ModelName; got != "" { + t.Fatalf("default model = %q, want cleared default", got) + } +} + +func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v2" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) { + t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String()) } } @@ -890,11 +1724,125 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModel if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5") + } +} + +func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "legacy-openrouter", + Model: "openrouter/openai/gpt-5.4", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := resp.Models[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if len(optionsByID) == 0 { + t.Fatal("provider_options should not be empty") + } + if option, ok := optionsByID["openai"]; !ok { + t.Fatal("openai provider option missing") + } else if option.DefaultAPIBase != "https://api.openai.com/v1" { + t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") + } + if option, ok := optionsByID["anthropic"]; !ok { + t.Fatal("anthropic provider option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := optionsByID["azure"]; !ok { + t.Fatal("azure provider option missing") + } + if option, ok := optionsByID["github-copilot"]; !ok { + t.Fatal("github-copilot provider option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } + if option, ok := optionsByID["elevenlabs"]; !ok { + t.Fatal("elevenlabs provider option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be marked as not allowed for default chat model selection") + } + } + if option, ok := optionsByID["lmstudio"]; !ok { + t.Fatal("lmstudio provider option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty api keys") + } + if option, ok := optionsByID["bedrock"]; !ok { + t.Fatal("bedrock provider option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime") + } + if option, ok := optionsByID["antigravity"]; !ok { + t.Fatal("antigravity provider option missing") + } else { + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "" { + t.Fatalf("persisted provider = %q, want unchanged empty provider", got) + } + if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { + t.Fatalf("persisted model = %q, want unchanged legacy model", got) } } @@ -942,6 +1890,115 @@ func TestHandleListModels_ReturnsProviderField(t *testing.T) { } } +func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + foundBedrock := false + for _, option := range resp.ProviderOptions { + if option.ID == "bedrock" { + foundBedrock = true + if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable in provider_options") + } + } + } + if !foundBedrock { + t.Fatal("bedrock should be included in provider_options for compatibility") + } +} + +func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "us-west-2", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "api_base":"us-east-1" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" { + t.Fatalf("model = %q, want updated bedrock model", got) + } + if got := updated.ModelList[0].APIBase; got != "us-east-1" { + t.Fatalf("api_base = %q, want %q", got, "us-east-1") + } +} + func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1053,6 +2110,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) { } } +func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ + "model_name": "elevenlabs-asr" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") { + t.Fatalf("body = %q, want default chat model rejection", rec.Body.String()) + } +} + func TestMaskAPIKey(t *testing.T) { tests := []struct { name string diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index c6c2deaae..3476e3c53 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -171,6 +171,12 @@ var toolCatalog = []toolCatalogEntry{ Category: "hardware", ConfigKey: "spi", }, + { + Name: "serial", + Description: "Interact with serial ports exposed on the host.", + Category: "hardware", + ConfigKey: "serial", + }, { Name: "tool_search_tool_regex", Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.", @@ -265,6 +271,8 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem { status, reasonCode = resolveWebSearchToolSupport(cfg) case "i2c", "spi": status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) + case "serial": + status, reasonCode = resolveSerialToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) default: if cfg.Tools.IsToolEnabled(entry.ConfigKey) { status = "enabled" @@ -293,6 +301,18 @@ func resolveHardwareToolSupport(enabled bool) (string, string) { return "enabled", "" } +func resolveSerialToolSupport(enabled bool) (string, string) { + if !enabled { + return "disabled", "" + } + switch runtime.GOOS { + case "linux", "darwin", "windows": + return "enabled", "" + default: + return "blocked", "requires_serial_platform" + } +} + func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) { if !cfg.Tools.IsToolEnabled("mcp") { return "disabled", "" @@ -362,6 +382,8 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error { cfg.Tools.I2C.Enabled = enabled case "spi": cfg.Tools.SPI.Enabled = enabled + case "serial": + cfg.Tools.Serial.Enabled = enabled case "tool_search_tool_regex": cfg.Tools.MCP.Discovery.UseRegex = enabled if enabled { diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go index c98067e41..a09a49fd6 100644 --- a/web/backend/api/tools_test.go +++ b/web/backend/api/tools_test.go @@ -92,9 +92,36 @@ func TestHandleListTools(t *testing.T) { if gotTools["i2c"].Status != "disabled" { t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status) } + if gotTools["serial"].Status != "disabled" { + t.Fatalf("serial status = %q, want disabled when config is off", gotTools["serial"].Status) + } + + cfg.Tools.Serial.Enabled = true + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools = make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + if gotTools["serial"].Status != "enabled" { + t.Fatalf("serial = %#v, want enabled on linux when config is on", gotTools["serial"]) + } } else { cfg.Tools.I2C.Enabled = true cfg.Tools.SPI.Enabled = true + cfg.Tools.Serial.Enabled = true if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -120,6 +147,16 @@ func TestHandleListTools(t *testing.T) { if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" { t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"]) } + switch runtime.GOOS { + case "darwin", "windows": + if gotTools["serial"].Status != "enabled" { + t.Fatalf("serial = %#v, want enabled on supported host", gotTools["serial"]) + } + default: + if gotTools["serial"].Status != "blocked" || gotTools["serial"].ReasonCode != "requires_serial_platform" { + t.Fatalf("serial = %#v, want blocked/requires_serial_platform", gotTools["serial"]) + } + } } } @@ -195,6 +232,26 @@ func TestHandleUpdateToolState(t *testing.T) { if !updated.Tools.Cron.Enabled { t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron) } + + rec4 := httptest.NewRecorder() + req4 := httptest.NewRequest( + http.MethodPut, + "/api/tools/serial/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req4.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec4, req4) + if rec4.Code != http.StatusOK { + t.Fatalf("serial status = %d, want %d, body=%s", rec4.Code, http.StatusOK, rec4.Body.String()) + } + + updated, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig(updated serial) error = %v", err) + } + if !updated.Tools.Serial.Enabled { + t.Fatalf("serial should be enabled: %#v", updated.Tools.Serial) + } } func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) { diff --git a/web/frontend/package.json b/web/frontend/package.json index ab07b40a2..bf3e7921b 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -19,15 +19,15 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@tabler/icons-react": "^3.40.0", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.0", - "@tanstack/react-router": "^1.168.23", + "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", "highlight.js": "^11.11.1", - "i18next": "^26.0.7", + "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.19.1", "radix-ui": "^1.4.3", @@ -43,7 +43,7 @@ "shadcn": "^4.3.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", "wrap-ansi": "^10.0.0" }, @@ -65,7 +65,7 @@ "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.59.0", + "typescript-eslint": "^8.59.1", "vite": "^8.0.10" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index cb5ca18de..78639de19 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -15,17 +15,17 @@ importers: specifier: ^3.40.0 version: 3.41.1(react@19.2.5) '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + specifier: ^4.2.4 + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) '@tanstack/react-query': specifier: ^5.99.0 version: 5.99.0(react@19.2.5) '@tanstack/react-router': - specifier: ^1.168.23 - version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.169.2 + version: 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router-devtools': specifier: ^1.166.13 - version: 1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -39,8 +39,8 @@ importers: specifier: ^11.11.1 version: 11.11.1 i18next: - specifier: ^26.0.7 - version: 26.0.7(typescript@5.9.3) + specifier: ^26.0.8 + version: 26.0.8(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -58,7 +58,7 @@ importers: version: 19.2.5(react@19.2.5) react-i18next: specifier: ^17.0.4 - version: 17.0.4(i18next@26.0.7(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + version: 17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) @@ -87,8 +87,8 @@ importers: specifier: ^3.5.0 version: 3.5.0 tailwindcss: - specifier: ^4.2.2 - version: 4.2.2 + specifier: ^4.2.4 + version: 4.2.4 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -98,13 +98,13 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.1(jiti@2.7.0)) '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@4.2.2) + version: 0.5.19(tailwindcss@4.2.4) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.3) @@ -119,22 +119,22 @@ importers: version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': specifier: ^8.58.2 - version: 8.58.2(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) eslint: specifier: ^10.2.1 - version: 10.2.1(jiti@2.6.1) + version: 10.2.1(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) + version: 10.1.8(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@10.2.1(jiti@2.6.1)) + version: 7.1.1(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-refresh: specifier: ^0.5.2 - version: 0.5.2(eslint@10.2.1(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.1(jiti@2.7.0)) globals: specifier: ^17.5.0 version: 17.5.0 @@ -148,11 +148,11 @@ importers: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.59.0 - version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.59.1 + version: 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) vite: specifier: ^8.0.10 - version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) packages: @@ -1459,69 +1459,69 @@ packages: '@tabler/icons@3.41.1': resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1532,20 +1532,20 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} engines: {node: '>= 20'} '@tailwindcss/typography@0.5.19': @@ -1553,8 +1553,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -1582,8 +1582,8 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.168.23': - resolution: {integrity: sha512-+GblieDnutG6oipJJPNtRJjrWF8QTZEG/l0532+BngFkVK48oHNOcvIkSoAFYftK1egAwM7KBxXsb0Ou+X6/MQ==} + '@tanstack/react-router@1.169.2': + resolution: {integrity: sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1595,16 +1595,15 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.168.15': - resolution: {integrity: sha512-Wr0424NDtD8fT/uALobMZ9DdcfsTyXtW5IPR++7zvW8/7RaIOeaqXpVDId8ywaGtqPWLWOfaUg2zUtYtukoXYA==} - engines: {node: '>=20.19'} - hasBin: true - '@tanstack/router-core@1.168.7': resolution: {integrity: sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ==} engines: {node: '>=20.19'} hasBin: true + '@tanstack/router-core@1.169.2': + resolution: {integrity: sha512-5sm0DJF1A7Mz+9gy4Gz/lLovNailK3yot4vYvz9MkBUPw26uLnhQiR8hSCYxucjE0wD6Mdlc5l+Z0/XTlZ7xHw==} + engines: {node: '>=20.19'} + '@tanstack/router-devtools-core@1.167.3': resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} engines: {node: '>=20.19'} @@ -1736,16 +1735,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/eslint-plugin@8.59.0': - resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.59.0 + '@typescript-eslint/parser': ^8.59.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.59.0': - resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1757,8 +1756,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.0': - resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -1767,8 +1766,8 @@ packages: resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.59.0': - resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.58.2': @@ -1777,8 +1776,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.0': - resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -1790,8 +1789,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.59.0': - resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1801,8 +1800,8 @@ packages: resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.0': - resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.58.2': @@ -1811,8 +1810,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.59.0': - resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -1824,8 +1823,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.0': - resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1835,12 +1834,13 @@ packages: resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.59.0': - resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} @@ -2204,8 +2204,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} entities@6.0.1: @@ -2596,8 +2596,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@26.0.7: - resolution: {integrity: sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ==} + i18next@26.0.8: + resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -2729,8 +2729,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.39: - resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} engines: {node: '>=18'} isexe@2.0.0: @@ -2743,8 +2743,8 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true jose@6.2.2: @@ -3563,8 +3563,8 @@ packages: peerDependencies: seroval: ^1.0 - seroval-plugins@1.5.2: - resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 @@ -3573,8 +3573,8 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} - seroval@1.5.2: - resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} serve-static@2.2.1: @@ -3709,11 +3709,11 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tiny-invariant@1.3.3: @@ -3784,8 +3784,8 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.59.0: - resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} + typescript-eslint@8.59.1: + resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4354,9 +4354,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))': dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4377,9 +4377,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.7.0))': optionalDependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} @@ -5350,78 +5350,78 @@ snapshots: '@tabler/icons@3.41.1': {} - '@tailwindcss/node@4.2.2': + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 + enhanced-resolve: 5.21.0 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/oxide-android-arm64@4.2.2': + '@tailwindcss/oxide-android-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.2': + '@tailwindcss/oxide-darwin-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.2': + '@tailwindcss/oxide-darwin-x64@4.2.4': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@tailwindcss/oxide-freebsd-x64@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.2': + '@tailwindcss/oxide-linux-x64-musl@4.2.4': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.2': + '@tailwindcss/oxide-wasm32-wasi@4.2.4': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': optional: true - '@tailwindcss/oxide@4.2.2': + '@tailwindcss/oxide@4.2.4': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/vite@4.2.2(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) '@tanstack/history@1.161.6': {} @@ -5432,23 +5432,23 @@ snapshots: '@tanstack/query-core': 5.99.0 react: 19.2.5 - '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.169.2)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3) + '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - '@tanstack/router-core': 1.168.15 + '@tanstack/router-core': 1.169.2 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/history': 1.161.6 '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-core': 1.168.15 - isbot: 5.1.39 + '@tanstack/router-core': 1.169.2 + isbot: 5.1.40 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -5459,13 +5459,6 @@ snapshots: react-dom: 19.2.5(react@19.2.5) use-sync-external-store: 1.6.0(react@19.2.5) - '@tanstack/router-core@1.168.15': - dependencies: - '@tanstack/history': 1.161.6 - cookie-es: 3.1.1 - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) - '@tanstack/router-core@1.168.7': dependencies: '@tanstack/history': 1.161.6 @@ -5473,9 +5466,16 @@ snapshots: seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3)': + '@tanstack/router-core@1.169.2': dependencies: - '@tanstack/router-core': 1.168.15 + '@tanstack/history': 1.161.6 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.169.2)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.169.2 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) optionalDependencies: @@ -5494,7 +5494,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5510,8 +5510,8 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + '@tanstack/react-router': 1.169.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5606,15 +5606,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.58.2 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5622,15 +5622,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/type-utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.0 - eslint: 10.2.1(jiti@2.6.1) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 10.2.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5638,31 +5638,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.1 debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -5673,38 +5673,38 @@ snapshots: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/scope-manager@8.59.0': + '@typescript-eslint/scope-manager@8.59.1': dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5712,7 +5712,7 @@ snapshots: '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/types@8.59.0': {} + '@typescript-eslint/types@8.59.1': {} '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': dependencies: @@ -5729,12 +5729,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 @@ -5744,24 +5744,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5771,17 +5771,17 @@ snapshots: '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.59.0': + '@typescript-eslint/visitor-keys@8.59.1': dependencies: - '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/types': 8.59.1 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) accepts@2.0.0: dependencies: @@ -6078,10 +6078,10 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.20.1: + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.2 + tapable: 2.3.3 entities@6.0.1: {} @@ -6136,24 +6136,24 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) - eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.7.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) eslint-scope@9.1.2: dependencies: @@ -6166,9 +6166,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.1(jiti@2.6.1): + eslint@10.2.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.5.5 @@ -6199,7 +6199,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -6596,7 +6596,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@26.0.7(typescript@5.9.3): + i18next@26.0.8(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -6682,7 +6682,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.39: {} + isbot@5.1.40: {} isexe@2.0.0: {} @@ -6690,7 +6690,7 @@ snapshots: javascript-natural-sort@0.7.1: {} - jiti@2.6.1: {} + jiti@2.7.0: {} jose@6.2.2: {} @@ -7505,11 +7505,11 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-i18next@17.0.4(i18next@26.0.7(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): + react-i18next@17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.0.7(typescript@5.9.3) + i18next: 26.0.8(typescript@5.9.3) react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: @@ -7719,13 +7719,13 @@ snapshots: dependencies: seroval: 1.5.1 - seroval-plugins@1.5.2(seroval@1.5.2): + seroval-plugins@1.5.4(seroval@1.5.4): dependencies: - seroval: 1.5.2 + seroval: 1.5.4 seroval@1.5.1: {} - seroval@1.5.2: {} + seroval@1.5.4: {} serve-static@2.2.1: dependencies: @@ -7896,9 +7896,9 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.2: {} + tailwindcss@4.2.4: {} - tapable@2.3.2: {} + tapable@2.3.3: {} tiny-invariant@1.3.3: {} @@ -7967,13 +7967,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8104,7 +8104,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0): + vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8115,7 +8115,7 @@ snapshots: '@types/node': 25.6.0 esbuild: 0.27.4 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 tsx: 4.21.0 void-elements@3.1.0: {} diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index d2d2dca88..5bb275fde 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -19,6 +19,7 @@ export interface ModelInfo { max_tokens_field?: string request_timeout?: number thinking_level?: string + tool_schema_transform?: string extra_body?: Record custom_headers?: Record // Meta @@ -26,12 +27,24 @@ export interface ModelInfo { status: "available" | "unconfigured" | "unreachable" is_default: boolean is_virtual: boolean + default_model_allowed?: boolean +} + +export interface ModelProviderOption { + id: string + default_api_base: string + empty_api_key_allowed: boolean + create_allowed: boolean + default_model_allowed: boolean + default_auth_method?: string + auth_method_locked?: boolean } interface ModelsListResponse { models: ModelInfo[] total: number default_model: string + provider_options: ModelProviderOption[] } interface ModelActionResponse { diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index 465d218be..700cc21e0 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -288,6 +288,9 @@ export function AppHeader() { i18n.changeLanguage("en")}> English + i18n.changeLanguage("pt-BR")}> + Português (Brasil) + i18n.changeLanguage("zh")}> 简体中文 diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 07a3c0abc..157ca636f 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -56,11 +56,38 @@ export function AssistantMessage({ const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" - const handleCopy = () => { - navigator.clipboard.writeText(content).then(() => { + const handleCopy = async () => { + const markCopied = () => { setIsCopied(true) setTimeout(() => setIsCopied(false), 2000) - }) + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(content) + markCopied() + return + } + } catch { + // HTTP 或受限环境下可能不支持 Clipboard API,继续走降级方案 + } + + const textArea = document.createElement("textarea") + textArea.value = content + textArea.setAttribute("readonly", "") + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + document.body.appendChild(textArea) + textArea.select() + + try { + const copied = document.execCommand("copy") + if (copied) { + markCopied() + } + } finally { + document.body.removeChild(textArea) + } } const collapsedLabel = isThought diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 741d9cde4..e0f51596a 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -1,8 +1,12 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { addModel, setDefaultModel } from "@/api/models" +import { + type ModelProviderOption, + addModel, + setDefaultModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -13,6 +17,13 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -25,6 +36,15 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { + findProviderOption, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + getProviderLabel, + getSortedProviderOptions, + isProviderAuthMethodLocked, +} from "./provider-label" + interface AddForm { modelName: string provider: string @@ -39,13 +59,14 @@ interface AddForm { maxTokensField: string requestTimeout: string thinkingLevel: string + toolSchemaTransform: string extraBody: string customHeaders: string } const EMPTY_ADD_FORM: AddForm = { modelName: "", - provider: "", + provider: "openai", model: "", apiBase: "", apiKey: "", @@ -57,6 +78,7 @@ const EMPTY_ADD_FORM: AddForm = { maxTokensField: "", requestTimeout: "", thinkingLevel: "", + toolSchemaTransform: "", extraBody: "", customHeaders: "", } @@ -66,6 +88,7 @@ interface AddModelSheetProps { onClose: () => void onSaved: () => void existingModelNames: string[] + providerOptions: ModelProviderOption[] } export function AddModelSheet({ @@ -73,6 +96,7 @@ export function AddModelSheet({ onClose, onSaved, existingModelNames, + providerOptions, }: AddModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState(EMPTY_ADD_FORM) @@ -86,6 +110,37 @@ export function AddModelSheet({ form.apiKey, t("models.field.apiKeyPlaceholder"), ) + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const creatableProviderOptions = useMemo( + () => sortedProviderOptions.filter((option) => option.create_allowed), + [sortedProviderOptions], + ) + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const isOAuth = effectiveAuthMethod === "oauth" + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault @@ -106,6 +161,9 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } + if (!selectedProviderOption) { + errors.provider = t("models.field.providerInvalid") + } if (!form.model.trim()) errors.model = t("models.add.errorRequired") setFieldErrors(errors) return Object.keys(errors).length === 0 @@ -120,22 +178,47 @@ export function AddModelSheet({ } } + const setProvider = (value: string) => { + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + if (fieldErrors.provider) { + setFieldErrors((prev) => ({ ...prev, provider: undefined })) + } + } + const handleSave = async () => { if (!validate()) return setSaving(true) setServerError("") try { const modelName = form.modelName.trim() - const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, - provider: provider || undefined, + provider: form.provider.trim(), model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, - auth_method: form.authMethod.trim() || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod.trim() || undefined, connect_mode: form.connectMode.trim() || undefined, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -144,6 +227,7 @@ export function AddModelSheet({ ? Number(form.requestTimeout) : undefined, thinking_level: form.thinkingLevel.trim() || undefined, + tool_schema_transform: form.toolSchemaTransform.trim() || undefined, extra_body: form.extraBody.trim() ? JSON.parse(form.extraBody.trim()) : undefined, @@ -205,12 +289,29 @@ export function AddModelSheet({ - + - - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - + {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + + )} - + @@ -266,12 +378,17 @@ export function AddModelSheet({ @@ -345,6 +462,17 @@ export function AddModelSheet({ /> + + + + void onSaved: () => void @@ -63,6 +86,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm { maxTokensField: model.max_tokens_field ?? "", requestTimeout: model.request_timeout ? String(model.request_timeout) : "", thinkingLevel: model.thinking_level ?? "", + toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA extraBody: model.extra_body ? JSON.stringify(model.extra_body, null, 2) : "", @@ -74,6 +98,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm { export function EditModelSheet({ model, + providerOptions, open, onClose, onSaved, @@ -92,6 +117,7 @@ export function EditModelSheet({ maxTokensField: "", requestTimeout: "", thinkingLevel: "", + toolSchemaTransform: "", extraBody: "", customHeaders: "", }) @@ -99,6 +125,42 @@ export function EditModelSheet({ const [setAsDefault, setSetAsDefault] = useState(false) const [error, setError] = useState("") const initialForm = model ? buildInitialEditForm(model) : null + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const currentProviderID = model + ? (findProviderOption(model.provider, providerOptions)?.id ?? + model.provider?.trim().toLowerCase() ?? + "") + : "" + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const providerError = selectedProviderOption + ? "" + : t("models.field.providerInvalid") + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const willClearDefaultOnSave = + model?.is_default === true && defaultModelAllowed === false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = model != null && (JSON.stringify(form) !== JSON.stringify(initialForm) || @@ -106,19 +168,56 @@ export function EditModelSheet({ useEffect(() => { if (model) { - setForm(buildInitialEditForm(model)) - setSetAsDefault(model.is_default) + const initialForm = buildInitialEditForm(model) + const option = findProviderOption(initialForm.provider, providerOptions) + if (option?.auth_method_locked && !initialForm.authMethod) { + initialForm.authMethod = option.default_auth_method ?? "" + } + setForm(initialForm) + setSetAsDefault(model.is_default && model.default_model_allowed !== false) setError("") } - }, [model]) + }, [model, providerOptions]) const setField = (key: keyof EditForm) => - (e: React.ChangeEvent) => + (e: React.ChangeEvent) => { + if (error) { + setError("") + } setForm((f) => ({ ...f, [key]: e.target.value })) + } + + const setProvider = (value: string) => { + if (error) { + setError("") + } + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + } const handleSave = async () => { if (!model) return + if (!selectedProviderOption) { + setError(providerError) + return + } if (!form.modelId.trim()) { setError(t("models.add.errorRequired")) return @@ -133,7 +232,9 @@ export function EditModelSheet({ api_base: form.apiBase || undefined, api_key: form.apiKey || undefined, proxy: form.proxy || undefined, - auth_method: form.authMethod || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod || undefined, connect_mode: form.connectMode || undefined, workspace: form.workspace || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -142,6 +243,7 @@ export function EditModelSheet({ ? Number(form.requestTimeout) : undefined, thinking_level: form.thinkingLevel || undefined, + tool_schema_transform: form.toolSchemaTransform.trim() || undefined, extra_body: form.extraBody.trim() ? JSON.parse(form.extraBody.trim()) : {}, @@ -168,7 +270,7 @@ export function EditModelSheet({ } } - const isOAuth = model?.auth_method === "oauth" + const isOAuth = effectiveAuthMethod === "oauth" const hasSavedAPIKey = Boolean(model?.api_key) const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( @@ -197,12 +299,36 @@ export function EditModelSheet({ - + @@ -263,12 +396,17 @@ export function EditModelSheet({ @@ -342,6 +480,17 @@ export function EditModelSheet({ /> + + + + { @@ -45,6 +48,9 @@ export function ModelCard({ return t("models.action.setDefaultDisabled.unavailable") if (model.is_default) return t("models.action.setDefaultDisabled.isDefault") if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual") + if (model.default_model_allowed === false) { + return t("models.action.setDefaultDisabled.unsupportedProvider") + } return setDefaultLabel })() diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 152c47585..df372b6b1 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + getModels, + setDefaultModel, +} from "@/api/models" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" @@ -12,41 +17,13 @@ import { refreshGatewayState } from "@/store/gateway" import { AddModelSheet } from "./add-model-sheet" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" -import { getProviderKey, getProviderLabel } from "./provider-label" +import { + PROVIDER_PRIORITY, + getProviderKey, + getProviderLabel, +} from "./provider-label" import { ProviderSection } from "./provider-section" -const PROVIDER_PRIORITY: Record = { - volcengine: 0, - openai: 1, - gemini: 2, - anthropic: 3, - zhipu: 4, - deepseek: 5, - openrouter: 6, - "qwen-portal": 7, - "qwen-intl": 8, - moonshot: 9, - groq: 10, - "github-copilot": 11, - antigravity: 12, - nvidia: 13, - cerebras: 14, - shengsuanyun: 15, - venice: 16, - vivgrid: 17, - minimax: 18, - longcat: 19, - modelscope: 20, - mistral: 21, - avian: 22, - azure: 23, - ollama: 24, - vllm: 25, - lmstudio: 26, - zai: 27, - mimo: 28, -} - interface ProviderGroup { key: string label: string @@ -58,6 +35,9 @@ interface ProviderGroup { export function ModelsPage() { const { t } = useTranslation() const [models, setModels] = useState([]) + const [providerOptions, setProviderOptions] = useState( + [], + ) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState("") @@ -67,6 +47,7 @@ export function ModelsPage() { const [settingDefaultIndex, setSettingDefaultIndex] = useState( null, ) + const addDisabled = loading || providerOptions.length === 0 const fetchModels = useCallback(async () => { try { @@ -79,6 +60,7 @@ export function ModelsPage() { return a.model_name.localeCompare(b.model_name) }) setModels(sorted) + setProviderOptions(data.provider_options ?? []) setFetchError("") } catch (e) { setFetchError(e instanceof Error ? e.message : t("models.loadError")) @@ -160,7 +142,12 @@ export function ModelsPage() {
- @@ -213,6 +200,7 @@ export function ModelsPage() { setEditingModel(null)} onSaved={fetchModels} @@ -220,6 +208,7 @@ export function ModelsPage() { setAddOpen(false)} onSaved={fetchModels} existingModelNames={models.map((model) => model.model_name)} diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 8d1cfe2c9..2ac728e76 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react" const PROVIDER_ICON_SLUGS: Record = { openai: "openai", + elevenlabs: "elevenlabs", anthropic: "anthropic", azure: "microsoftazure", gemini: "googlegemini", @@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record = { const PROVIDER_DOMAINS: Record = { openai: "openai.com", + elevenlabs: "elevenlabs.io", anthropic: "anthropic.com", azure: "azure.com", gemini: "gemini.google.com", diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts index 123640fe5..75eb81e53 100644 --- a/web/frontend/src/components/models/provider-label.ts +++ b/web/frontend/src/components/models/provider-label.ts @@ -1,11 +1,19 @@ +import type { ModelProviderOption } from "@/api/models" + const PROVIDER_LABELS: Record = { openai: "OpenAI", + bedrock: "AWS Bedrock", + elevenlabs: "ElevenLabs ASR", anthropic: "Anthropic", + "anthropic-messages": "Anthropic Messages", azure: "Azure OpenAI", gemini: "Google Gemini", deepseek: "DeepSeek", + "coding-plan": "Alibaba Coding Plan", + "coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)", "qwen-portal": "Qwen (阿里云)", "qwen-intl": "Qwen International", + "qwen-us": "Qwen US", moonshot: "Moonshot (月之暗面)", groq: "Groq", openrouter: "OpenRouter", @@ -15,8 +23,11 @@ const PROVIDER_LABELS: Record = { shengsuanyun: "ShengsuanYun (神算云)", antigravity: "Google Code Assist", "github-copilot": "GitHub Copilot", + "claude-cli": "Claude CLI (local)", + "codex-cli": "Codex CLI (local)", ollama: "Ollama (local)", lmstudio: "LM Studio (local)", + litellm: "LiteLLM", mistral: "Mistral AI", avian: "Avian", vllm: "VLLM (local)", @@ -28,6 +39,7 @@ const PROVIDER_LABELS: Record = { minimax: "MiniMax", longcat: "LongCat", modelscope: "ModelScope (魔搭社区)", + novita: "Novita AI", } const PROVIDER_ALIASES: Record = { @@ -40,6 +52,48 @@ const PROVIDER_ALIASES: Record = { "google-antigravity": "antigravity", } +export const PROVIDER_PRIORITY: Record = { + volcengine: 0, + openai: 1, + gemini: 2, + anthropic: 3, + bedrock: 4, + elevenlabs: 5, + "anthropic-messages": 6, + zhipu: 7, + deepseek: 8, + openrouter: 9, + "qwen-portal": 10, + "qwen-intl": 11, + "qwen-us": 12, + moonshot: 13, + groq: 14, + "coding-plan": 15, + "coding-plan-anthropic": 16, + "github-copilot": 17, + antigravity: 18, + nvidia: 19, + cerebras: 20, + shengsuanyun: 21, + venice: 22, + vivgrid: 23, + minimax: 24, + longcat: 25, + modelscope: 26, + mistral: 27, + avian: 28, + novita: 29, + azure: 30, + litellm: 31, + ollama: 32, + vllm: 33, + lmstudio: 34, + "claude-cli": 35, + "codex-cli": 36, + zai: 37, + mimo: 38, +} + export function getProviderKey(provider?: string): string { const normalized = provider?.trim().toLowerCase() if (!normalized) return "openai" @@ -50,3 +104,45 @@ export function getProviderLabel(provider?: string): string { const prefix = getProviderKey(provider) return PROVIDER_LABELS[prefix] ?? prefix } + +export function findProviderOption( + provider: string | undefined, + options: ModelProviderOption[], +): ModelProviderOption | undefined { + const providerKey = getProviderKey(provider) + return options.find((option) => option.id === providerKey) +} + +export function getProviderDefaultAPIBase( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_api_base ?? "" +} + +export function getSortedProviderOptions( + options: ModelProviderOption[], +): ModelProviderOption[] { + return [...options].sort((a, b) => { + const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER + const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER + if (aPriority !== bPriority) { + return aPriority - bPriority + } + return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id)) + }) +} + +export function getProviderDefaultAuthMethod( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_auth_method ?? "" +} + +export function isProviderAuthMethodLocked( + provider: string | undefined, + options: ModelProviderOption[], +): boolean { + return findProviderOption(provider, options)?.auth_method_locked === true +} diff --git a/web/frontend/src/features/chat/assistant-message-state.ts b/web/frontend/src/features/chat/assistant-message-state.ts index 54eb6163f..c26b2665c 100644 --- a/web/frontend/src/features/chat/assistant-message-state.ts +++ b/web/frontend/src/features/chat/assistant-message-state.ts @@ -19,14 +19,25 @@ export interface AssistantMessageUpdateState { toolCalls?: AssistantToolCalls } +function normalizeAssistantMessageKind( + payload: Record, +): string | undefined { + if (typeof payload.kind !== "string") { + return undefined + } + const kind = payload.kind.trim().toLowerCase() + return kind || undefined +} + function parseAssistantMessageKind( payload: Record, toolCalls?: AssistantToolCalls, ): AssistantMessageKind { - if (payload.thought === true) { + const kind = normalizeAssistantMessageKind(payload) + if (kind === "thought") { return "thought" } - if (payload.kind === "tool_calls" || toolCalls) { + if (kind === "tool_calls" || toolCalls) { return "tool_calls" } return "normal" @@ -36,8 +47,7 @@ function hasExplicitAssistantKindPayload( payload: Record, ): boolean { return ( - typeof payload.thought === "boolean" || - payload.kind === "tool_calls" || + normalizeAssistantMessageKind(payload) !== undefined || payload.tool_calls !== undefined ) } diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts index 337bea8db..98566f70f 100644 --- a/web/frontend/src/hooks/use-chat-models.ts +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -27,17 +27,26 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { const [defaultModelName, setDefaultModelName] = useState("") const setDefaultRequestIdRef = useRef(0) + const syncDefaultModelName = useCallback( + (models: ModelInfo[], defaultModel: string) => { + if (models.some((m) => m.model_name === defaultModel)) { + setDefaultModelName(defaultModel) + return + } + setDefaultModelName("") + }, + [], + ) + const loadModels = useCallback(async () => { try { const data = await getModels() setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) } catch { // silently fail } - }, []) + }, [syncDefaultModelName]) useEffect(() => { const timerId = setTimeout(() => { @@ -60,9 +69,7 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { } setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) const gateway = await refreshGatewayState({ force: true }) showSaveSuccessOrRestartToast( t, @@ -75,30 +82,41 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { toast.error(err instanceof Error ? err.message : t("models.loadError")) } }, - [defaultModelName, t], + [defaultModelName, syncDefaultModelName, t], + ) + + const defaultSelectableModels = useMemo( + () => + modelList.filter( + (m) => m.default_model_allowed !== false && m.is_virtual !== true, + ), + [modelList], ) const hasAvailableModels = useMemo( - () => modelList.some((m) => m.available), - [modelList], + () => defaultSelectableModels.some((m) => m.available), + [defaultSelectableModels], ) const oauthModels = useMemo( - () => modelList.filter((m) => m.available && m.auth_method === "oauth"), - [modelList], + () => + defaultSelectableModels.filter( + (m) => m.available && m.auth_method === "oauth", + ), + [defaultSelectableModels], ) const localModels = useMemo( - () => modelList.filter((m) => m.available && isLocalModel(m)), - [modelList], + () => defaultSelectableModels.filter((m) => m.available && isLocalModel(m)), + [defaultSelectableModels], ) const apiKeyModels = useMemo( () => - modelList.filter( + defaultSelectableModels.filter( (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m), ), - [modelList], + [defaultSelectableModels], ) return { diff --git a/web/frontend/src/i18n/index.ts b/web/frontend/src/i18n/index.ts index bdc1fe917..4da7b3f0d 100644 --- a/web/frontend/src/i18n/index.ts +++ b/web/frontend/src/i18n/index.ts @@ -1,5 +1,6 @@ import dayjs from "dayjs" import "dayjs/locale/en" +import "dayjs/locale/pt-br" import "dayjs/locale/zh-cn" import localizedFormat from "dayjs/plugin/localizedFormat" import relativeTime from "dayjs/plugin/relativeTime" @@ -8,6 +9,7 @@ import LanguageDetector from "i18next-browser-languagedetector" import { initReactI18next } from "react-i18next" import en from "./locales/en.json" +import ptBr from "./locales/pt-br.json" import zh from "./locales/zh.json" dayjs.extend(relativeTime) @@ -26,6 +28,9 @@ i18n en: { translation: en, }, + "pt-BR": { + translation: ptBr, + }, zh: { translation: zh, }, @@ -41,6 +46,8 @@ i18n i18n.on("languageChanged", (lng) => { if (lng.startsWith("zh")) { dayjs.locale("zh-cn") + } else if (lng.startsWith("pt")) { + dayjs.locale("pt-br") } else { dayjs.locale("en") } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 75a17e791..029691aba 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -236,7 +236,8 @@ "setting": "Setting as default...", "unavailable": "Cannot set unavailable model as default", "isDefault": "Already the default model", - "isVirtual": "Cannot set virtual model as default" + "isVirtual": "Cannot set virtual model as default", + "unsupportedProvider": "This provider is ASR-only and cannot be the default chat model" }, "deleteDisabled": { "isDefault": "Cannot delete the default model" @@ -244,7 +245,9 @@ }, "defaultOnSave": { "label": "Default Model", - "description": "Automatically set this model as default after saving." + "description": "Automatically set this model as default after saving.", + "unsupportedProvider": "This provider can be saved in model_list, but it cannot be used as the default chat model.", + "clearOnSave": "Saving this ASR-only model will clear the current default chat model selection." }, "add": { "button": "Add Model", @@ -255,7 +258,7 @@ "modelNameHint": "A short name used to identify this model in conversations.", "modelId": "Model Identifier", "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o", - "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.", + "modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.", "errorRequired": "This field is required.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.", "saveError": "Failed to add model", @@ -272,8 +275,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "e.g. openai", - "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", + "providerPlaceholder": "Select a provider", + "providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.", + "providerInvalid": "The current Provider is invalid. Select a supported Provider.", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "Enter your API key", @@ -282,6 +286,7 @@ "proxyHint": "Optional. e.g. http://127.0.0.1:7890", "authMethod": "Auth Method", "authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.", + "authMethodManagedHint": "This Provider manages its authentication mode automatically.", "connectMode": "Connect Mode", "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", "workspace": "Workspace Path", @@ -294,6 +299,8 @@ "thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.", "maxTokensField": "Max Tokens Field", "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.", + "toolSchemaTransform": "Tool Schema Transform", + "toolSchemaTransformHint": "Optional compatibility transform for tool JSON schemas. Leave blank for native behavior. Supported values: simple.", "extraBody": "Extra Body", "extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.", "customHeaders": "Custom Headers", @@ -603,6 +610,7 @@ }, "reasons": { "requires_linux": "This tool only works on Linux hosts with the required device files exposed.", + "requires_serial_platform": "This tool currently supports Linux, macOS, and Windows hosts with accessible serial ports.", "requires_skills": "Enable `tools.skills` before this skill-registry tool can be used.", "requires_subagent": "Enable `tools.subagent` before the spawn tool can delegate work.", "requires_mcp_discovery": "Enable `tools.mcp.discovery` before MCP discovery tools become available.", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json new file mode 100644 index 000000000..c091625bb --- /dev/null +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -0,0 +1,753 @@ +{ + "navigation": { + "chat": "Chat", + "model_group": "Modelos", + "models": "Modelos", + "credentials": "Credenciais", + "agent_group": "Agente", + "hub": "Hub", + "skills": "Skills", + "tools": "Ferramentas", + "services": "Serviços", + "channels_group": "Canais", + "show_more_channels": "Mais", + "show_less_channels": "Menos", + "config": "Configuração", + "logs": "Logs" + }, + "launcherLogin": { + "title": "Entrar", + "description": "Digite a senha do dashboard para continuar.", + "passwordLabel": "Senha", + "passwordPlaceholder": "Digite a senha", + "submit": "Entrar", + "errorInvalid": "Senha incorreta. Tente novamente.", + "errorNetwork": "Erro de rede. Tente novamente." + }, + "launcherSetup": { + "title": "Definir senha do dashboard", + "description": "Escolha uma senha para proteger o acesso a este dashboard. Você a usará toda vez que entrar.", + "passwordLabel": "Senha", + "passwordPlaceholder": "Pelo menos 8 caracteres", + "confirmLabel": "Confirmar senha", + "confirmPlaceholder": "Repita a senha", + "submit": "Definir senha", + "errorMismatch": "As senhas não coincidem.", + "errorNetwork": "Erro de rede. Tente novamente." + }, + "chat": { + "welcome": "Como posso te ajudar hoje?", + "welcomeDesc": "Pergunte sobre clima, configurações ou qualquer outra tarefa. Estou aqui para ajudar.", + "placeholder": "Inicie uma nova mensagem...", + "disabledPlaceholder": { + "gatewayUnknown": "Não é possível conversar: o status do Gateway ainda está sendo verificado. Aguarde e atualize a página ou reinicie o Launcher se necessário.", + "gatewayStarting": "Não é possível conversar: o Gateway está iniciando. Aguarde a inicialização concluir e tente novamente.", + "gatewayRestarting": "Não é possível conversar: o Gateway está reiniciando. Aguarde o reinício terminar.", + "gatewayStopping": "Não é possível conversar: o Gateway está parando. Aguarde até que pare e inicie o Gateway novamente.", + "gatewayStopped": "Não é possível conversar: o Gateway não está iniciado. Clique em Iniciar Gateway na barra superior e tente novamente.", + "gatewayError": "Não é possível conversar: o Gateway está em estado de erro. Verifique os logs e reinicie o Gateway ou o Launcher.", + "websocketConnecting": "Conectando ao serviço de chat... Aguarde.", + "websocketDisconnected": "Não é possível conversar: a conexão WebSocket está desconectada. Verifique a rede e o status do gateway, atualize a página ou reinicie o Launcher.", + "websocketError": "Não é possível conversar: a conexão WebSocket falhou. Verifique a rede e o status do gateway e tente novamente.", + "noDefaultModel": "Não é possível conversar: nenhum modelo padrão está selecionado. Defina um modelo padrão na página de Modelos." + }, + "newChat": "Novo Chat", + "notConnected": "O Gateway não está rodando. Inicie-o para conversar.", + "thinking": { + "step1": "Pensando...", + "step2": "Analisando sua solicitação...", + "step3": "Preparando resposta...", + "step4": "Quase lá..." + }, + "reasoningLabel": "Raciocínio", + "toolCallsLabel": "Chamadas de ferramentas", + "toolCallExplanationLabel": "Nota da chamada", + "toolCallFunctionLabel": "Resumo da chamada", + "showAssistantDetails": "Mostrar raciocínio e chamadas de ferramentas", + "toolLabel": "Ferramenta", + "history": "Histórico", + "noHistory": "Nenhum histórico de chat ainda", + "historyLoadFailed": "Falha ao carregar histórico de chat", + "historyOpenFailed": "Falha ao abrir este histórico de chat", + "loadingMore": "Carregando mais...", + "deleteSession": "Excluir sessão", + "messagesCount": "{{count}} mensagens", + "noModel": "Selecionar modelo", + "inputDisabled": { + "notConnected": "O Gateway não está rodando. Inicie-o para conversar.", + "noModel": "Nenhum modelo padrão configurado. Vá para a página de Modelos para definir um." + }, + "sendMessage": "Enviar mensagem", + "sendHint": "Pressione Enter para enviar\nShift + Enter para nova linha", + "contextTitle": "Contexto", + "contextDetail": "Ver Detalhes", + "attachImage": "Adicionar imagens", + "removeImage": "Remover imagem", + "uploadedImage": "Imagem enviada", + "invalidImage": "\"{{name}}\" não é um arquivo de imagem suportado.", + "imageTooLarge": "\"{{name}}\" excede o limite de {{size}}.", + "imageReadFailed": "Falha ao ler \"{{name}}\".", + "empty": { + "noConfiguredModel": "Nenhum Modelo Configurado", + "noConfiguredModelDescription": "Você precisa configurar pelo menos um modelo de IA com uma API Key antes de iniciar o chat.", + "goToModels": "Ir para Modelos", + "noSelectedModel": "Nenhum Modelo Selecionado", + "noSelectedModelDescription": "Você tem modelos configurados, mas nenhum está definido como padrão. Selecione um modelo antes de iniciar o chat.", + "notRunning": "Gateway Não Está Rodando", + "notRunningDescription": "Inicie o serviço de gateway para começar a conversar. Use o botão Iniciar Gateway na barra superior." + }, + "modelGroup": { + "apikey": "API Key", + "oauth": "OAuth", + "local": "Local" + } + }, + "header": { + "logout": { + "tooltip": "Sair", + "confirm": "Sair", + "description": "Tem certeza de que deseja sair do dashboard?" + }, + "gateway": { + "stopDialog": { + "title": "Parar o Serviço de Gateway?", + "description": "Tem certeza de que deseja parar o gateway? Isso desconectará suas sessões de chat ativas e interromperá a inferência.", + "confirm": "Parar Gateway" + }, + "action": { + "start": "Iniciar Gateway", + "stop": "Parar Gateway", + "restart": "Reiniciar Gateway" + }, + "status": { + "starting": "Iniciando Gateway...", + "restarting": "Reiniciando Gateway...", + "stopping": "Parando Gateway..." + }, + "restartRequired": "Alterações de configuração requerem reiniciar o gateway para ter efeito." + } + }, + "common": { + "cancel": "Cancelar", + "save": "Salvar", + "saving": "Salvando...", + "reset": "Redefinir", + "confirm": "Confirmar", + "saveChangesTitle": "Você tem alterações de configuração não salvas", + "restartRequiredTitle": "Reinício do gateway necessário", + "restartRequiredDesc": "A configuração mais recente de {{name}} foi salva. Reinicie o gateway para que tenha efeito." + }, + "labels": { + "loading": "Carregando..." + }, + "footer": { + "version": "Versão", + "commit": "Commit", + "build": "Build", + "version_unknown": "Desconhecido" + }, + "credentials": { + "description": "Gerencie credenciais OAuth e baseadas em token para os provedores suportados.", + "loading": "Carregando credenciais...", + "providers": { + "openai": { + "description": "Suporta OAuth via navegador, device code e login por token." + }, + "anthropic": { + "description": "Usa login por token para acesso ao Claude." + }, + "antigravity": { + "description": "Usa OAuth via navegador para o Google Cloud Code Assist." + } + }, + "status": { + "connected": "Conectado", + "needsRefresh": "Precisa atualizar", + "expired": "Expirado", + "notLoggedIn": "Não autenticado" + }, + "actions": { + "browser": "OAuth via Navegador", + "deviceCode": "Device Code", + "stopLoading": "Parar Carregamento", + "saveToken": "Salvar", + "logout": "Sair" + }, + "logoutDialog": { + "title": "Sair do provedor?", + "description": "Isso removerá sua credencial salva para {{provider}}." + }, + "fields": { + "openaiToken": "Token OpenAI", + "anthropicToken": "Token Anthropic" + }, + "labels": { + "account": "Conta", + "email": "Email", + "project": "Projeto" + }, + "errors": { + "loadFailed": "Falha ao carregar credenciais", + "flowFailed": "Falha ao verificar fluxo de autenticação", + "loginFailed": "Falha no login", + "logoutFailed": "Falha ao sair", + "invalidBrowserResponse": "Resposta de login do navegador inválida", + "invalidDeviceResponse": "Resposta de device code inválida", + "popupBlocked": "Não foi possível abrir uma nova aba. Permita popups e tente novamente." + }, + "flow": { + "current": "Status atual de autenticação", + "pending": "Aguardando autorização...", + "success": "Autenticação bem-sucedida", + "error": "Falha na autenticação", + "expired": "Sessão de autenticação expirada" + }, + "device": { + "title": "Login por Device do OpenAI", + "description": "Abra a página de verificação e digite o código abaixo. Esta página será atualizada automaticamente.", + "code": "Código do Usuário", + "url": "URL de Verificação", + "polling": "Verificando status do login...", + "open": "Abrir Página de Verificação" + } + }, + "models": { + "description": "Configure API Keys para provedores de IA. Apenas modelos configurados ficam disponíveis para o chat.", + "defaultChangeSuccess": "Modelo padrão atualizado.", + "unsavedPrompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração do modelo.", + "restartHint": "Alterações na configuração de modelos só têm efeito após o gateway reiniciar.", + "loadError": "Falha ao carregar modelos", + "noDefaultHintPrefix": "Nenhum modelo padrão definido ainda. Clique em", + "noDefaultHintSuffix": "para definir um.", + "status": { + "available": "Disponível", + "unconfigured": "Não configurado", + "unreachable": "Serviço inacessível" + }, + "badge": { + "default": "Padrão", + "virtual": "Virtual" + }, + "action": { + "edit": "Editar API Key", + "setDefault": "Definir como padrão", + "delete": "Excluir modelo", + "setDefaultDisabled": { + "setting": "Definindo como padrão...", + "unavailable": "Não é possível definir um modelo indisponível como padrão", + "isDefault": "Já é o modelo padrão", + "isVirtual": "Não é possível definir um modelo virtual como padrão" + }, + "deleteDisabled": { + "isDefault": "Não é possível excluir o modelo padrão" + } + }, + "defaultOnSave": { + "label": "Modelo Padrão", + "description": "Definir automaticamente este modelo como padrão após salvar." + }, + "add": { + "button": "Adicionar Modelo", + "title": "Adicionar Modelo Customizado", + "description": "Adicione um endpoint de modelo nativo ou compatível com OpenAI.", + "modelName": "Apelido do Modelo", + "modelNamePlaceholder": "ex: meu-gpt4", + "modelNameHint": "Um nome curto usado para identificar este modelo nas conversas.", + "modelId": "Identificador do Modelo", + "modelIdPlaceholder": "ex: gpt-4o ou openai/gpt-4o", + "modelIdHint": "Se Provider não estiver especificado, valores como openai/gpt-4o são interpretados no formato provider/modelo. Se Provider estiver especificado, este campo é tratado como o ID canônico do modelo e não é parseado em busca de prefixo de provider.", + "errorRequired": "Este campo é obrigatório.", + "errorDuplicateModelName": "Apelido de modelo já existe. Use um nome diferente.", + "saveError": "Falha ao adicionar modelo", + "saveSuccess": "Modelo adicionado.", + "confirm": "Adicionar Modelo" + }, + "delete": { + "title": "Excluir Modelo?", + "description": "\"{{name}}\" será removido permanentemente da sua lista de modelos. Esta ação não pode ser desfeita.", + "confirm": "Excluir" + }, + "advanced": { + "toggle": "Opções avançadas" + }, + "field": { + "provider": "Provider", + "providerPlaceholder": "ex: openai", + "providerHint": "Opcional. Se especificado, este valor é usado como o provider efetivo, e Identificador do Modelo é interpretado como o ID canônico do modelo.", + "apiBase": "URL Base da API", + "apiKey": "API Key", + "apiKeyPlaceholder": "Digite sua API Key", + "apiKeyPlaceholderSet": "Deixe em branco para manter a chave existente", + "proxy": "Proxy HTTP", + "proxyHint": "Opcional. ex: http://127.0.0.1:7890", + "authMethod": "Método de Autenticação", + "authMethodHint": "Método de autenticação: oauth, token. Deixe em branco para autenticação por API Key.", + "connectMode": "Modo de Conexão", + "connectModeHint": "Modo de conexão para providers baseados em CLI: stdio ou grpc.", + "workspace": "Caminho do Workspace", + "workspaceHint": "Diretório de trabalho para providers baseados em CLI (ex: GitHub Copilot).", + "requestTimeout": "Timeout da Requisição (s)", + "requestTimeoutHint": "Tempo máximo em segundos para aguardar uma resposta. 0 = usar padrão.", + "rpm": "Limite de Taxa (RPM)", + "rpmHint": "Máximo de requisições por minuto. 0 = sem limite.", + "thinkingLevel": "Nível de Pensamento", + "thinkingLevelHint": "Orçamento de pensamento estendido: off, low, medium, high, xhigh, adaptive.", + "maxTokensField": "Campo de Max Tokens", + "maxTokensFieldHint": "Sobrescreve o nome do campo de max tokens na requisição, ex: max_completion_tokens.", + "extraBody": "Body Extra", + "extraBodyHint": "Campos JSON adicionais para injetar no body da requisição, ex: {\"reasoning_split\": true}.", + "customHeaders": "Headers Customizados", + "customHeadersHint": "Headers HTTP adicionais para injetar em cada requisição, ex: {\"X-Source\": \"coding-plan\"}." + }, + "edit": { + "title": "Configurar {{name}}", + "apiKeyHint": "Já existe uma chave definida. Deixe em branco para mantê-la inalterada.", + "oauthNote": "Este provider usa OAuth — não é necessária API Key.", + "saveError": "Falha ao salvar", + "saveSuccess": "Configuração do modelo salva." + } + }, + "channels": { + "loadError": "Falha ao carregar canais", + "name": { + "telegram": "Telegram", + "discord": "Discord", + "slack": "Slack", + "feishu": "Feishu", + "dingtalk": "DingTalk", + "line": "LINE", + "qq": "QQ", + "onebot": "OneBot", + "wecom": "WeCom", + "whatsapp": "WhatsApp", + "whatsapp_native": "WhatsApp Nativo", + "pico": "Web", + "maixcam": "MaixCam", + "matrix": "Matrix", + "irc": "IRC", + "weixin": "WeChat" + }, + "weixin": { + "bindTitle": "Vincular Conta do WeChat", + "bindDesc": "Escaneie o QR code com o WeChat para vincular sua conta pessoal.", + "bind": "Vincular WeChat", + "rebind": "Re-vincular", + "bound": "WeChat Vinculado", + "notBound": "Conta do WeChat ainda não vinculada.", + "generating": "Gerando QR code...", + "scanHint": "Abra o WeChat e escaneie o QR code", + "scanned": "Escaneado — confirme no WeChat", + "expired": "QR code expirado", + "retry": "Tentar Novamente", + "refresh": "Atualizar QR", + "errorGeneric": "Ocorreu um erro. Tente novamente." + }, + "wecom": { + "bindTitle": "Vincular WeCom", + "bindDesc": "Escaneie o QR code com o WeCom para vincular seu AI Bot.", + "bind": "Vincular WeCom", + "rebind": "Re-vincular", + "bound": "WeCom Vinculado", + "notBound": "AI Bot do WeCom ainda não vinculado.", + "generating": "Gerando QR code...", + "scanHint": "Abra o WeCom e escaneie o QR code", + "scanned": "Escaneado, confirme no WeCom", + "expired": "QR code expirado", + "retry": "Tentar Novamente", + "refresh": "Atualizar QR", + "errorGeneric": "Ocorreu um erro. Tente novamente." + }, + "field": { + "token": "Token do Bot", + "tokenPlaceholder": "Digite o token do bot", + "botToken": "Token do Bot", + "appToken": "App Token", + "appId": "App ID", + "appSecret": "App Secret", + "verificationToken": "Token de Verificação", + "encryptKey": "Chave de Criptografia", + "baseUrl": "URL Base da API", + "proxy": "Proxy HTTP", + "mentionOnly": "Apenas com Menção", + "typingEnabled": "Indicador de Digitação", + "placeholderEnabled": "Mensagem de Placeholder", + "placeholderText": "Texto do Placeholder", + "groupTriggerMentionOnly": "Apenas Menção em Grupo", + "groupTriggerPrefixes": "Prefixos de Trigger em Grupo", + "groupTriggerPrefixesPlaceholder": "ex: /, !, ?", + "randomReactionEmoji": "Emoji de Reação Aleatório", + "randomReactionEmojiPlaceholder": "ex: THUMBSUP, HEART, SMILE", + "isLark": "Lark (Internacional)", + "allowFrom": "Permitir De", + "allowFromPlaceholder": "ex: 123456, 789012", + "allowOrigins": "Origens Permitidas", + "allowOriginsPlaceholder": "ex: https://exemplo.com, http://localhost:5173", + "removeListItem": "Remover {{value}}", + "secretPlaceholder": "Digite o segredo", + "secretHintSet": "Já existe um valor definido. Deixe em branco para mantê-lo inalterado." + }, + "page": { + "notFound": "Canal \"{{name}}\" não é suportado.", + "saveSuccess": "Configuração do canal salva.", + "saveError": "Falha ao salvar configuração do canal", + "savePrompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração do canal.", + "docLink": "Documentação", + "enableLabel": "Habilitar canal", + "restartRequiredTitle": "Reinício do gateway necessário", + "restartRequiredDesc": "A configuração mais recente de {{name}} foi salva. Reinicie o gateway para que tenha efeito." + }, + "form": { + "desc": { + "token": "Token de acesso do bot usado para conectar à API da plataforma.", + "botToken": "Token do bot usado para enviar e receber mensagens.", + "appToken": "App token usado para conexões em modo Socket.", + "appId": "ID único da aplicação usado para autenticação.", + "appSecret": "Segredo da aplicação usado para assinatura e autenticação.", + "verificationToken": "Token de verificação para callbacks de eventos.", + "encryptKey": "Chave de criptografia usada para descriptografar payloads de callback.", + "baseUrl": "URL base da API da plataforma. O endpoint oficial é usado por padrão.", + "proxy": "Endereço de proxy HTTP para acesso de rede de saída.", + "mentionOnly": "Responder apenas quando o bot for explicitamente mencionado em chats em grupo.", + "typingEnabled": "Exibir status de digitação enquanto o assistente está gerando uma resposta.", + "placeholderEnabled": "Habilitar mensagens de placeholder temporárias antes da resposta final ser enviada.", + "groupTriggerMentionOnly": "Em chats em grupo, responder apenas quando o bot for mencionado.", + "groupTriggerPrefixes": "Prefixos customizados de trigger para chats em grupo. Adicione itens um a um ou cole vários valores de uma vez.", + "randomReactionEmoji": "PicoClaw adiciona reações de emoji às mensagens dos usuários para confirmar recebimento. Exemplo: \"THUMBSUP\", \"HEART\", \"SMILE\". Deixe vazio para usar o emoji \"Pin\" padrão.", + "isLark": "Usar o domínio internacional do Lark (open.larksuite.com) em vez do domínio do Feishu (open.feishu.cn).", + "allowFrom": "IDs de usuário ou grupo permitidos. Adicione itens um a um ou cole vários valores de uma vez.", + "allowOrigins": "Domínios de origem permitidos. Adicione itens um a um ou cole vários valores de uma vez.", + "wsUrl": "URL do serviço WebSocket.", + "reconnectInterval": "Intervalo de reconexão após desconexão (segundos).", + "bridgeUrl": "URL do serviço de bridge.", + "sessionStorePath": "Caminho local para armazenamento de sessões.", + "useNative": "Se deve usar modo de cliente nativo.", + "host": "Endereço do host do serviço.", + "port": "Porta do serviço.", + "homeserver": "URL do homeserver Matrix.", + "userId": "ID de usuário da conta.", + "deviceId": "ID do dispositivo.", + "joinOnInvite": "Entrar automaticamente em salas quando convidado.", + "clientId": "Client ID usado para autenticação na plataforma.", + "corpId": "Corp ID corporativo.", + "agentId": "Agent ID da aplicação corporativa.", + "webhookUrl": "URL completa do webhook.", + "webhookHost": "Host de escuta do webhook.", + "webhookPort": "Porta de escuta do webhook.", + "webhookPath": "Caminho de rota do webhook.", + "replyTimeout": "Timeout de resposta em segundos.", + "maxSteps": "Número máximo de passos de processamento.", + "welcomeMessage": "Conteúdo da mensagem de boas-vindas para novas sessões.", + "allowTokenQuery": "Permitir token nos parâmetros de query da URL.", + "pingInterval": "Intervalo de heartbeat da conexão em segundos.", + "readTimeout": "Timeout de leitura em segundos.", + "writeTimeout": "Timeout de escrita em segundos.", + "maxConnections": "Número máximo de conexões concorrentes.", + "server": "Endereço do servidor IRC.", + "tls": "Se deve habilitar TLS.", + "nick": "Apelido do bot.", + "user": "Nome de usuário do IRC.", + "realName": "Nome real exibido.", + "channels": "Canais IRC para entrar.", + "requestCaps": "Lista de capabilities IRC requisitada na conexão.", + "maxBase64FileSizeMiB": "Tamanho máximo em MiB para converter arquivos locais em base64 antes do upload. 0 significa ilimitado. Aplica-se apenas a arquivos locais, não a uploads via URL.", + "genericField": "Usado para configurar {{field}}." + } + }, + "validation": { + "requiredField": "Este campo é obrigatório." + } + }, + "pages": { + "agent": { + "load_error": "Falha ao carregar informações de suporte do agente.", + "skills": { + "empty": "Nenhuma skill disponível no momento.", + "install_success": "{{name}} instalada.", + "install_error": "Falha ao instalar skill.", + "search_placeholder": "Pesquisar por nome, descrição ou registry", + "source_label": "Tipo", + "sort_label": "Ordenar", + "import": "Importar Skill", + "import_success": "Skill importada.", + "import_error": "Falha ao importar skill.", + "import_invalid_type": "Apenas arquivos de skill em Markdown ou ZIP são suportados.", + "import_invalid_size": "O arquivo de skill deve ter 1 MB ou menos.", + "import_constraints": "Importe um arquivo de skill em Markdown ou ZIP de até 1 MB", + "view": "Visualizar", + "delete": "Excluir", + "delete_title": "Excluir Skill?", + "delete_description": "\"{{name}}\" será removida das skills do workspace.", + "delete_confirm": "Excluir", + "delete_success": "Skill excluída.", + "delete_error": "Falha ao excluir skill.", + "viewer_title": "Conteúdo da Skill", + "viewer_description": "Leia aqui o conteúdo efetivo atual de SKILL.md.", + "load_detail_error": "Falha ao carregar conteúdo da skill.", + "no_description": "Nenhuma descrição fornecida.", + "no_results": "Nenhuma skill corresponde aos filtros atuais.", + "dropzone_title": "Importar para o Workspace", + "dropzone_description": "Arraste um arquivo de skill aqui ou escolha um do disco.", + "dropzone_label": "Solte um arquivo de skill aqui", + "dropzone_active": "Solte para importar esta skill", + "dropzone_release": "A skill será normalizada e salva no diretório de skills do workspace.", + "marketplace_title": "Descobrir Skills", + "marketplace_description": "Pesquise nos registries de skills e instale skills úteis neste workspace", + "marketplace_search_placeholder": "Pesquise capacidades como github, docker, database...", + "marketplace_search_action": "Pesquisar", + "marketplace_search_status": "Status da Pesquisa", + "marketplace_install_status": "Status da Instalação", + "marketplace_notice_title": "Aviso de Segurança", + "marketplace_notice_body": "Skills do registry são conteúdo de terceiros. Revise o autor, URL da página, instruções e qualquer código ou credencial requerida antes de instalar.", + "marketplace_status_disabled": "Desabilitado. Habilite a ferramenta correspondente na página de Ferramentas primeiro.", + "marketplace_status_enable_hint": "Habilite a ferramenta relacionada na página de Ferramentas primeiro.", + "marketplace_search_error": "Falha ao pesquisar registries.", + "marketplace_loading_results": "Pesquisando skills...", + "marketplace_loading_more": "Carregando mais skills...", + "marketplace_results_title": "{{count}} resultados para “{{query}}”", + "marketplace_results_hint": "Resultados do registry instalam no workspace atual.", + "marketplace_install_action": "Instalar", + "marketplace_installed": "Instalada", + "marketplace_view_installed": "Ver Local", + "marketplace_installed_hint": "Já disponível neste workspace como “{{name}}”.", + "marketplace_empty_results": "Nenhuma skill instalável encontrada para “{{query}}”.", + "marketplace_idle": "Pesquise por uma capacidade para descobrir skills instaláveis nos registries configurados.", + "marketplace_unavailable": "Pesquisa de registries indisponível no momento. Verifique a configuração das ferramentas de Skills.", + "sort": { + "name_asc": "Nome (A-Z)", + "name_desc": "Nome (Z-A)", + "source": "Tipo" + }, + "origin": { + "all": "Todos os Tipos", + "builtin": "Embutida", + "third_party": "Terceiros", + "manual": "Manual" + }, + "summary": { + "total": "Total de Skills" + }, + "detail_tabs": { + "preview": "Visualização", + "raw": "Bruto", + "meta": "Metadados" + }, + "metadata": { + "name": "Nome", + "description": "Descrição", + "registry": "Registry", + "url": "URL", + "version": "Versão Instalada", + "lines": "Quantidade de Linhas", + "characters": "Quantidade de Caracteres" + }, + "marketplace_installDisabled": { + "installing": "Instalando...", + "installed": "Já instalada", + "cannotInstall": "Não é possível instalar: ferramenta relacionada não está habilitada" + } + }, + "tools": { + "search_placeholder": "Pesquisar ferramentas...", + "no_results": "Nenhuma ferramenta corresponde aos seus critérios.", + "filter": { + "all": "Todos os Status", + "enabled": "Habilitada", + "disabled": "Desabilitada", + "blocked": "Bloqueada" + }, + "empty": "Nenhuma ferramenta disponível.", + "enable_success": "Ferramenta habilitada.", + "disable_success": "Ferramenta desabilitada.", + "toggle_error": "Falha ao atualizar estado da ferramenta.", + "library_title": "Biblioteca de Ferramentas", + "library_description": "Navegue e gerencie o conjunto de ferramentas disponíveis para seus agentes de IA.", + "web_search": { + "title": "Pesquisa Web", + "description": "Fornece capacidade de pesquisa web aos agentes para encontrar informações atualizadas do mundo real. Roteia automaticamente para o provedor ativo ideal.", + "unsaved_prompt": "Esta alteração ainda não foi salva. Salve para gravá-la na configuração de Pesquisa Web.", + "global_settings": "Geral", + "providers_config": "Integrações", + "load_error": "Falha ao carregar configuração de pesquisa web.", + "save": "Salvar Alterações", + "open_settings": "Abrir Configurações", + "save_success": "Configurações salvas com sucesso.", + "save_error": "Falha ao salvar configurações.", + "provider": "Provedor Principal", + "provider_description": "Selecione o provedor padrão a ser usado quando a ferramenta de pesquisa web atender a uma requisição.", + "proxy": "Proxy HTTPS", + "proxy_description": "Proxy HTTP/S global opcional para requisições web subjacentes.", + "prefer_native": "Preferir Pesquisa Nativa", + "prefer_native_hint": "Quando habilitado, o modelo pode usar sua capacidade de pesquisa nativa em vez da lista de provedores configurados.", + "provider_hint": "Habilite este provedor e preencha as configurações de conexão necessárias.", + "max_results": "Máx. de Resultados", + "base_url": "URL Base", + "base_url_placeholder": "Sobrescrita opcional do endpoint", + "api_key": "API Key / Token", + "api_key_placeholder": "Digite a API Key, deixe em branco para manter a chave original", + "none": "Indisponível" + }, + "status": { + "enabled": "Habilitada", + "disabled": "Desabilitada", + "blocked": "Bloqueada" + }, + "categories": { + "automation": "Automação", + "filesystem": "Sistema de Arquivos", + "web": "Web", + "communication": "Comunicação", + "skills": "Skills", + "agents": "Agentes", + "hardware": "Hardware", + "discovery": "Descoberta" + }, + "reasons": { + "requires_linux": "Esta ferramenta só funciona em hosts Linux com os arquivos de dispositivo necessários expostos.", + "requires_serial_platform": "Esta ferramenta atualmente suporta hosts Linux, macOS e Windows com portas seriais acessíveis.", + "requires_skills": "Habilite `tools.skills` antes que esta ferramenta de skill-registry possa ser usada.", + "requires_subagent": "Habilite `tools.subagent` antes que a ferramenta de spawn possa delegar trabalho.", + "requires_mcp_discovery": "Habilite `tools.mcp.discovery` antes que as ferramentas de descoberta MCP fiquem disponíveis.", + "requires_web_search_provider": "Configure ao menos um provedor externo de pesquisa web pronto para uso." + } + } + }, + "config": { + "load_error": "Falha ao carregar configuração. Atualize a página e tente novamente.", + "workspace": "Diretório do Workspace", + "workspace_hint": "Diretório base para operações de arquivo do agente.", + "restrict_workspace": "Restringir ao Workspace", + "restrict_workspace_hint": "Permitir operações de arquivo apenas dentro do workspace.", + "split_on_marker": "Modo Tagarela", + "split_on_marker_hint": "Dividir mensagens longas em várias curtas, como em uma conversa real.", + "tool_feedback_enabled": "Feedback de Ferramentas", + "tool_feedback_enabled_hint": "Enviar uma breve nota de execução no chat atual antes de cada ferramenta rodar.", + "tool_feedback_separate_messages": "Mensagens de Feedback Separadas", + "tool_feedback_separate_messages_hint": "Manter cada atualização de feedback de ferramenta como uma mensagem própria no chat em vez de reusar uma única mensagem de placeholder/progresso.", + "tool_feedback_max_args_length": "Tamanho do Preview de Args da Ferramenta", + "tool_feedback_max_args_length_hint": "Número máximo de caracteres exibidos em cada preview de argumento da ferramenta. Defina 0 para usar o padrão.", + "exec_enabled": "Permitir Comandos", + "exec_enabled_hint": "Habilita ou desabilita execução de comandos para o app. Quando desabilitado, nenhuma requisição de comando rodará.", + "allow_remote": "Permitir Comandos Remotos", + "allow_remote_hint": "Quando habilitado, sessões remotas ou contextos não locais também podem executar comandos. Quando desabilitado, a execução de comandos fica limitada a contextos locais seguros.", + "enable_deny_patterns": "Habilitar Lista Negra", + "enable_deny_patterns_hint": "Quando habilitado, o app bloqueia comandos que correspondam aos seus padrões perigosos embutidos e à lista negra customizada abaixo.", + "exec_timeout_seconds": "Timeout de Comando (segundos)", + "exec_timeout_seconds_hint": "Tempo máximo de execução para requisições de comando. Defina 0 para usar o timeout padrão.", + "custom_deny_patterns": "Lista Negra de Comandos", + "custom_deny_patterns_hint": "Adicione regras extras de bloqueio de comando, uma expressão regular por linha. Um comando que casar com qualquer regra aqui será bloqueado.", + "custom_allow_patterns": "Lista Branca de Comandos", + "custom_allow_patterns_hint": "Adicione regras extras de permissão de comando, uma expressão regular por linha. Um comando que casar com qualquer regra aqui pula a verificação da lista negra, mas outros limites de segurança ainda se aplicam.", + "custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b", + "pattern_detector_title": "Ferramenta de Detecção de Padrões", + "pattern_detector_hint": "Digite um comando para testar se ele casa com algum padrão da lista negra ou branca.", + "pattern_detector_input_placeholder": "Digite um comando para testar, ex: rm -rf /tmp", + "pattern_detector_test_button": "Testar", + "pattern_detector_result_allowed": "Permitido (corresponde à lista branca)", + "pattern_detector_result_blocked": "Bloqueado (corresponde à lista negra)", + "pattern_detector_result_no_match": "Sem correspondência (usará as regras padrão)", + "allow_shell_execution": "Permitir Comandos Agendados", + "allow_shell_execution_hint": "Permitir que tarefas agendadas executem comandos por padrão. Quando desabilitado, usuários precisam passar command_confirm=true para agendar uma tarefa de comando.", + "cron_exec_timeout": "Timeout de Comando Agendado (minutos)", + "cron_exec_timeout_hint": "Tempo máximo de execução para comandos agendados. Defina 0 para desabilitar o timeout.", + "max_tokens": "Max Tokens", + "max_tokens_hint": "Limite superior de tokens por resposta do modelo.", + "context_window": "Janela de Contexto", + "context_window_hint": "Capacidade do contexto de entrada do modelo em tokens. Deixe vazio para usar o padrão (4x max tokens).", + "max_tool_iterations": "Máx. de Iterações de Ferramenta", + "max_tool_iterations_hint": "Loops máximos de chamadas de ferramenta em uma única tarefa.", + "summarize_threshold": "Limite para Resumir Mensagens", + "summarize_threshold_hint": "Iniciar resumo após este número de mensagens.", + "summarize_token_percent": "Percentual de Token para Resumir", + "summarize_token_percent_hint": "Usado quando o resumo da conversa é acionado.", + "session_scope": "Escopo da Sessão", + "session_scope_hint": "Como o contexto do chat é isolado entre peers/canais.", + "session_scope_per_channel_peer": "Por Canal + Peer", + "session_scope_per_channel_peer_desc": "Contexto separado para cada usuário em cada canal.", + "session_scope_per_channel": "Por Canal", + "session_scope_per_channel_desc": "Um contexto compartilhado por canal.", + "session_scope_per_peer": "Por Peer", + "session_scope_per_peer_desc": "Um contexto por usuário entre canais.", + "session_scope_global": "Global", + "session_scope_global_desc": "Todas as mensagens compartilham um contexto global.", + "heartbeat_enabled": "Heartbeat", + "heartbeat_enabled_hint": "Enviar mensagens de heartbeat periódicas.", + "heartbeat_interval": "Intervalo do Heartbeat (minutos)", + "heartbeat_interval_hint": "Intervalo em minutos entre sinais de heartbeat.", + "devices_enabled": "Habilitar Dispositivos", + "devices_enabled_hint": "Habilitar integrações com dispositivos de hardware.", + "monitor_usb": "Monitorar USB", + "monitor_usb_hint": "Observar eventos de plug/unplug USB quando dispositivos estiverem habilitados.", + "autostart_label": "Iniciar no Login", + "autostart_hint": "Iniciar o PicoClaw Web automaticamente quando você fizer login.", + "autostart_unsupported": "Iniciar no login não é suportado nesta plataforma.", + "autostart_load_error": "Falha ao carregar status de iniciar no login.", + "server_port": "Porta do Serviço", + "server_port_hint": "Porta HTTP usada pelo PicoClaw Web.", + "launcher_section_hint": "Alterações nesta seção entram em vigor após o launcher reiniciar.", + "gateway_restart_hint": "Alterações nesta seção entram em vigor após o gateway reiniciar.", + "dashboard_password": "Senha de Login", + "dashboard_password_hint": "Defina uma nova senha de login.", + "dashboard_password_placeholder": "Pelo menos 8 caracteres", + "dashboard_password_confirm": "Confirmar Nova Senha", + "dashboard_password_confirm_hint": "Digite a nova senha de login novamente.", + "dashboard_password_confirm_placeholder": "Repita a senha", + "dashboard_password_required": "Digite e confirme a nova senha de login.", + "dashboard_password_mismatch": "As senhas de login não coincidem.", + "dashboard_password_min_length": "A senha de login deve ter pelo menos 8 caracteres.", + "lan_access": "Habilitar Acesso pela LAN", + "lan_access_hint": "Permitir acesso de outros dispositivos na sua rede local.", + "allowed_cidrs": "CIDRs de Rede Permitidos", + "allowed_cidrs_hint": "Apenas clientes destes intervalos CIDR podem acessar o serviço. Um por linha ou separados por vírgula. Deixe vazio para permitir todos.", + "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", + "sections": { + "agent": "Agente", + "runtime": "Runtime", + "exec": "Execução de Comandos", + "cron": "Tarefas Agendadas", + "launcher": "Launcher", + "devices": "Dispositivos" + }, + "open_raw": "Configuração Bruta", + "back_to_visual": "Configuração Visual", + "raw_json_title": "Configuração JSON Bruta", + "json_placeholder": "Digite uma configuração JSON válida...", + "save_success": "Configuração salva com sucesso.", + "save_error": "Falha ao salvar configuração.", + "reset_confirm_title": "Redefinir Alterações", + "reset_confirm_desc": "Tem certeza de que deseja redefinir suas alterações não salvas para o último estado salvo?", + "reset_success": "Alterações foram redefinidas para o último estado salvo.", + "invalid_json": "Formato JSON inválido.", + "format_success": "JSON formatado com sucesso.", + "format_error": "Formato JSON inválido.", + "format": "Formatar", + "unsaved_changes": "Você tem alterações não salvas." + }, + "logs": { + "log_level_error": "Falha ao atualizar nível de log.", + "clear": "Limpar logs", + "empty": "Aguardando logs..." + } + }, + "tour": { + "skip": "Pular tour", + "prev": "Anterior", + "next": "Próximo", + "finish": "Concluir", + "welcome": { + "title": "Bem-vindo ao PicoClaw", + "description": "PicoClaw é uma plataforma poderosa de assistente de IA. Vamos levar alguns segundos para te ajudar a concluir a configuração básica." + }, + "models": { + "title": "Configurar Modelos", + "description": "Clique no menu \"Modelos\" à esquerda para configurar API Keys dos provedores de IA. Apenas modelos configurados podem ser usados no chat." + }, + "gateway": { + "title": "Iniciar Gateway", + "description": "Após configurar modelos, clique no botão \"Iniciar Gateway\" no topo para começar a conversar com a IA." + }, + "docs": { + "title": "Ver Documentação", + "description": "Precisa de mais ajuda? Clique no botão de documentação no canto superior direito para ver guias detalhados e documentação de configuração." + } + } +} diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 0a140605a..c2076135e 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -236,7 +236,8 @@ "setting": "正在设为默认...", "unavailable": "无法将不可用的模型设为默认", "isDefault": "该模型已是默认模型", - "isVirtual": "无法将虚拟模型设为默认" + "isVirtual": "无法将虚拟模型设为默认", + "unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型" }, "deleteDisabled": { "isDefault": "无法删除默认模型" @@ -244,7 +245,9 @@ }, "defaultOnSave": { "label": "默认模型", - "description": "保存后自动将该模型设置为默认模型。" + "description": "保存后自动将该模型设置为默认模型。", + "unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。", + "clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。" }, "add": { "button": "添加模型", @@ -255,7 +258,7 @@ "modelNameHint": "用于在对话中识别此模型的简短名称。", "modelId": "模型标识符", "modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o", - "modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。", + "modelIdHint": "此字段将作为所选 Provider 的规范模型 ID 使用。若模型标识符本身包含斜杠(如 openai/gpt-5.4),将作为完整 ID 保留,不会再次拆分 Provider。", "errorRequired": "此字段为必填项。", "errorDuplicateModelName": "模型别名已存在,请使用其他名称。", "saveError": "添加模型失败", @@ -272,8 +275,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "例如 openai", - "providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。", + "providerPlaceholder": "请选择 Provider", + "providerHint": "请选择一个由后端 catalog 提供的 Provider;“模型标识符”字段会按该 Provider 的规范模型 ID 解释。", + "providerInvalid": "当前 Provider 无效,请重新选择一个受支持的 Provider。", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "请输入 API Key", @@ -282,6 +286,7 @@ "proxyHint": "可选。例如 http://127.0.0.1:7890", "authMethod": "认证方式", "authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。", + "authMethodManagedHint": "该 Provider 的认证方式由系统自动管理。", "connectMode": "连接模式", "connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。", "workspace": "工作目录", @@ -294,6 +299,8 @@ "thinkingLevelHint": "扩展思考预算:off、low、medium、high、xhigh、adaptive。", "maxTokensField": "Max Tokens 字段名", "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。", + "toolSchemaTransform": "工具 Schema 转换", + "toolSchemaTransformHint": "可选的工具 JSON Schema 兼容性转换。留空表示保持原生行为。当前支持值:simple。", "extraBody": "Extra Body", "extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。", "customHeaders": "Custom Headers", @@ -603,6 +610,7 @@ }, "reasons": { "requires_linux": "该工具仅在 Linux 主机上可用,并且需要暴露对应的设备文件。", + "requires_serial_platform": "该工具当前支持 Linux、macOS 和 Windows,且要求主机可访问对应串口。", "requires_skills": "需要先启用 `tools.skills`,该技能注册表工具才能使用。", "requires_subagent": "需要先启用 `tools.subagent`,`spawn` 才能委派任务。", "requires_mcp_discovery": "需要先启用 `tools.mcp.discovery`,MCP 发现工具才会可用。",