diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3d72ace94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure shell scripts always use LF line endings regardless of OS. +*.sh text eol=lf +docker/entrypoint.sh text eol=lf diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 000000000..4da3f79cb --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,60 @@ +name: Create Tag + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag name (required, e.g. v0.2.0)" + required: true + type: string + commit: + description: "Target commit SHA (leave empty for latest main)" + required: false + type: string + default: "" + +jobs: + create-tag: + name: Create Git Tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: main + + - name: Validate commit exists + if: ${{ inputs.commit != '' }} + shell: bash + run: | + if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then + echo "::error::Commit '${{ inputs.commit }}' does not exist." + exit 1 + fi + + - name: Check tag does not already exist + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then + echo "::error::Tag '${{ inputs.tag }}' already exists." + exit 1 + fi + + - name: Create and push tag + shell: bash + run: | + TARGET="${{ inputs.commit || 'HEAD' }}" + COMMIT_SHA=$(git rev-parse "$TARGET") + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}" + git push origin "${{ inputs.tag }}" + echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY" + echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml index 67fded40a..626318619 100644 --- a/.github/workflows/create_dmg.yml +++ b/.github/workflows/create_dmg.yml @@ -24,7 +24,7 @@ jobs: go-version-file: go.mod - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.33.0 run_install: false diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0e619dd27..d507234dc 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -48,7 +48,7 @@ jobs: go-version-file: go.mod - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.33.0 run_install: false @@ -74,7 +74,10 @@ 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 }} @@ -86,6 +89,10 @@ 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: @@ -94,7 +101,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + REPO_OWNER: ${{ steps.repo.outputs.owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} @@ -144,3 +151,154 @@ 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 c887bf493..9aa054943 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ -name: Create Tag and Release +name: Release on: workflow_dispatch: inputs: tag: - description: "Release tag (required, e.g. v0.2.0)" + description: "Existing tag to release (e.g. v0.2.0)" required: true type: string prerelease: @@ -24,35 +24,23 @@ on: default: true jobs: - create-tag: - name: Create Git Tag - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Create and push tag - shell: bash - env: - RELEASE_TAG: ${{ inputs.tag }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" - git push origin "$RELEASE_TAG" - release: name: GoReleaser Release - needs: create-tag runs-on: ubuntu-latest permissions: contents: write packages: write steps: + - name: Verify tag exists + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then + echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow." + exit 1 + fi + - name: Checkout tag uses: actions/checkout@v6 with: @@ -66,7 +54,7 @@ jobs: go-version-file: go.mod - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.33.0 run_install: false @@ -92,7 +80,10 @@ 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 }} @@ -101,6 +92,10 @@ 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: @@ -109,7 +104,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + REPO_OWNER: ${{ steps.repo.outputs.owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} INCLUDE_ANDROID_BUNDLE: "true" @@ -128,9 +123,149 @@ 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 + needs: [release, patch-macos-archives] if: ${{ inputs.upload_tos }} uses: ./.github/workflows/upload-tos.yml with: diff --git a/.gitignore b/.gitignore index 135867842..e1736f56b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ dist/ # Windows Application Icon/Resource *.syso +.cache/ +web/frontend/.pnpm-store/ +_tmp_* +web/frontend/_tmp_* # Test telegram integration cmd/telegram/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d8c51b069..b330c60f5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -151,8 +151,8 @@ dockers_v2: ids: - picoclaw images: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' + - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw" + - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}' tags: - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' @@ -168,8 +168,8 @@ dockers_v2: - picoclaw-launcher - picoclaw-launcher-tui images: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' + - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw" + - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}' tags: - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' @@ -224,7 +224,7 @@ nfpms: {{- else if eq .Arch "arm" }}armv{{ .Arm }} {{- else }}{{ .Arch }}{{ end }} vendor: picoclaw - homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw + homepage: https://github.com/{{ .Env.REPO_OWNER }}/picoclaw maintainer: picoclaw contributors description: picoclaw - a tool for managing and running tasks license: MIT diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbb6a6347..a78c41c36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,8 @@ We are committed to maintaining a welcoming and respectful community. Be kind, c For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction. +For documentation contributions, prefer the layout and naming conventions in [`docs/README.md`](docs/README.md). Run `make lint-docs` after adding or moving Markdown files to catch common consistency issues early. + --- ## Getting Started @@ -64,7 +66,7 @@ For substantial new features, please open an issue first to discuss the design b ```bash make build # Build binary (runs go generate first) make generate # Run go generate only -make check # Full pre-commit check: deps + fmt + vet + test +make check # Full pre-commit check: deps + fmt + vet + test + docs consistency checks ``` ### Running Tests @@ -81,9 +83,10 @@ go test -bench=. -benchmem -run='^$' ./... # Run benchmarks make fmt # Format code make vet # Static analysis make lint # Full linter run +make lint-docs # Check common documentation layout and naming conventions ``` -All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early. +All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early, including the common docs consistency checks from `make lint-docs`. --- diff --git a/Makefile b/Makefile index afaa7c29a..acb258370 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build install uninstall clean help test build-all +.PHONY: all build install uninstall clean help test build-all lint-docs # Build variables BINARY_NAME=picoclaw @@ -7,19 +7,43 @@ CMD_DIR=cmd/$(BINARY_NAME) MAIN_GO=$(CMD_DIR)/main.go EXT= +ifeq ($(OS),Windows_NT) + POWERSHELL=powershell -NoProfile -Command + WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL)) +endif + # Version -VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") -BUILD_TIME=$(shell date +%FT%T%z) -GO_VERSION=$(shell $(GO) version | awk '{print $$3}') +ifeq ($(OS),Windows_NT) + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL)) + BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'")) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL)) +else + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null)) + BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z)) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null)) +endif +VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev) +GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev) +BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev) +GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown) CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config 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 # Go variables -GO?=CGO_ENABLED=0 go +GO?=go WEB_GO?=$(GO) +CGO_ENABLED?=0 GO_BUILD_TAGS?=goolm,stdjson GOFLAGS?=-v -tags $(GO_BUILD_TAGS) +GOCACHE?=$(CURDIR)/.cache/go-build +GOMODCACHE?=$(CURDIR)/.cache/go-mod +GOTOOLCHAIN?=local +export CGO_ENABLED +export GOCACHE +export GOMODCACHE +export GOTOOLCHAIN comma:=, empty:= space:=$(empty) $(empty) @@ -73,8 +97,21 @@ BUILTIN_SKILLS_DIR=$(CURDIR)/skills LNCMD=ln -sf # OS detection -UNAME_S?=$(shell uname -s) -UNAME_M?=$(shell uname -m) +ifeq ($(OS),Windows_NT) + UNAME_S=Windows + ifeq ($(WINDOWS_GOARCH_RAW),amd64) + UNAME_M=x86_64 + else ifeq ($(WINDOWS_GOARCH_RAW),arm64) + UNAME_M=arm64 + else ifeq ($(WINDOWS_GOARCH_RAW),386) + UNAME_M=x86 + else + UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64) + endif +else + UNAME_S?=$(shell uname -s) + UNAME_M?=$(shell uname -m) +endif # Platform-specific settings ifeq ($(UNAME_S),Linux) @@ -122,6 +159,18 @@ else endif +ifeq ($(OS),Windows_NT) + PLATFORM=windows + ifeq ($(UNAME_M),x86_64) + ARCH?=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH?=arm64 + else + ARCH?=$(UNAME_M) + endif + EXT=.exe +endif + BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) # Default target @@ -130,21 +179,37 @@ all: build ## generate: Run generate generate: @echo "Run generate..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "if (Test-Path -LiteralPath './$(CMD_DIR)/workspace') { Remove-Item -LiteralPath './$(CMD_DIR)/workspace' -Recurse -Force }" +else @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true +endif @$(GO) generate ./... @echo "Run generate complete" ## build: Build the picoclaw binary for current platform build: generate @echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" + @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR) + @$(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) @echo "Build complete: $(BINARY_PATH)$(EXT)" @$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT) +endif + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)$(EXT)" ## build-launcher: Build the picoclaw-launcher (web console) binary build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" + @$(MAKE) -C web build PLATFORM="$(PLATFORM)" ARCH="$(ARCH)" EXT="$(EXT)" OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" GO_BUILD_TAGS="$(GO_BUILD_TAGS)" + @$(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 \ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \ @@ -152,6 +217,7 @@ build-launcher: GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ LDFLAGS='$(LDFLAGS)' @$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT) +endif @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)" build-launcher-frontend: @@ -160,10 +226,16 @@ build-launcher-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 - @echo "Build complete: $(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 @@ -290,7 +362,11 @@ uninstall-all: ## clean: Remove build artifacts clean: @echo "Cleaning build artifacts..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "if (Test-Path -LiteralPath '$(BUILD_DIR)') { Remove-Item -LiteralPath '$(BUILD_DIR)' -Recurse -Force }" +else @rm -rf $(BUILD_DIR) +endif @echo "Clean complete" ## vet: Run go vet for static analysis @@ -308,9 +384,14 @@ test: generate fmt: @$(GOLANGCI_LINT) fmt +## lint-docs: Check common documentation layout and naming conventions +lint-docs: + @./scripts/lint-docs.sh + ## lint: Run linters lint: @$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS) + @./scripts/lint-docs.sh ## fix: Fix linting issues fix: @@ -326,8 +407,8 @@ update-deps: @$(GO) get -u ./... @$(GO) mod tidy -## check: Run vet, fmt, and verify dependencies -check: deps fmt vet test +## check: Run deps, fmt, vet, tests, and docs consistency checks +check: deps fmt vet test lint-docs ## run: Build and run picoclaw run: build diff --git a/README.md b/README.md index 1ab514a29..73cc877fa 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | **English** +[中文](docs/project/README.zh.md) | [日本語](docs/project/README.ja.md) | [한국어](docs/project/README.ko.md) | [Português](docs/project/README.pt-br.md) | [Tiếng Việt](docs/project/README.vi.md) | [Français](docs/project/README.fr.md) | [Italiano](docs/project/README.it.md) | [Bahasa Indonesia](docs/project/README.id.md) | [Malay](docs/project/README.ms.md) | **English** @@ -112,7 +112,7 @@ _*Recent builds may use 10-20MB due to rapid PR merges. Resource optimization is -> **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR! +> **[Hardware Compatibility List](docs/guides/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!

PicoClaw Hardware Compatibility @@ -309,6 +309,7 @@ Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. @@ -379,7 +380,7 @@ This creates `~/.picoclaw/config.json` and the workspace directory. > See `config/config.example.json` in the repo for a complete configuration template with all available options. > -> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details. +> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security/security_configuration.md` for more details. **3. Chat** @@ -458,7 +459,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use } ``` -For full provider configuration details, see [Providers & Models](docs/providers.md). +For full provider configuration details, see [Providers & Models](docs/guides/providers.md). @@ -470,8 +471,8 @@ Talk to your PicoClaw through 18+ messaging platforms: |---------|-------|----------|------| | **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) | | **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) | -| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) | -| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) | +| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/guides/chat-apps.md#whatsapp) | +| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/guides/chat-apps.md#weixin) | | **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) | | **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) | | **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) | @@ -480,7 +481,7 @@ Talk to your PicoClaw through 18+ messaging platforms: | **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) | | **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) | | **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) | -| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) | +| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) | | **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) | | **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) | | **Pico** | Easy (enable) | Native protocol | Built-in | @@ -488,9 +489,9 @@ Talk to your PicoClaw through 18+ messaging platforms: > All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server. -> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details. +> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/guides/configuration.md#gateway-log-level) for details. -For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md). +For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md). ## 🔧 Tools @@ -510,7 +511,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too ### ⚙️ Other Tools -PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details. +PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details. ## 🎯 Skills @@ -547,7 +548,7 @@ Add to your `config.json`: `tools.skills.github.*` is deprecated. Use `tools.skills.registries.github.*` instead. -For more details, see [Tools Configuration - Skills](docs/tools_configuration.md#skills-tool). +For more details, see [Tools Configuration - Skills](docs/reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -570,7 +571,20 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a } ``` -For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/tools_configuration.md#mcp-tool). +You can manage common MCP setups directly from the CLI instead of editing JSON by hand: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +picoclaw mcp list +picoclaw mcp test filesystem +``` + +`picoclaw mcp` is a configuration manager: it updates `config.json` under `tools.mcp.servers`, but it does not keep the server process running itself. + +Use `picoclaw mcp edit` when you need advanced fields that are not covered by `picoclaw mcp add`. +For example, `picoclaw mcp add` supports `--deferred` and `--env-file`, while `picoclaw mcp edit` is still useful for direct JSON editing and uncommon MCP settings. + +For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). For CLI usage and examples, see [MCP Server CLI](docs/reference/mcp-cli.md). ## ClawdChat Join the Agent Social Network @@ -590,6 +604,11 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message | `picoclaw status` | Show status | | `picoclaw version` | Show version info | | `picoclaw model` | View or switch the default model | +| `picoclaw mcp list` | List configured MCP servers | +| `picoclaw mcp add ...` | Add or update an MCP server entry | +| `picoclaw mcp test` | Probe a configured MCP server | +| `picoclaw mcp edit` | Open config for advanced MCP editing | +| `picoclaw mcp remove` | Remove an MCP server entry | | `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron add ...` | Add a scheduled job | | `picoclaw cron disable` | Disable a scheduled job | @@ -607,7 +626,7 @@ PicoClaw supports scheduled reminders and recurring tasks through the `cron` too * **Recurring tasks**: "Remind me every 2 hours" -> triggers every 2 hours * **Cron expressions**: "Remind me at 9am daily" -> uses cron expression -See [docs/cron.md](docs/cron.md) for current schedule types, execution modes, command-job gates, and persistence details. +See [docs/reference/cron.md](docs/reference/cron.md) for current schedule types, execution modes, command-job gates, and persistence details. ## 📚 Documentation @@ -615,18 +634,19 @@ For detailed guides beyond this README: | Topic | Description | |-------|-------------| -| [Docker & Quick Start](docs/docker.md) | Docker Compose setup, Launcher/Agent modes | -| [Chat Apps](docs/chat-apps.md) | All 17+ channel setup guides | -| [Configuration](docs/configuration.md) | Environment variables, workspace layout, security sandbox | -| [Scheduled Tasks and Cron Jobs](docs/cron.md) | Cron schedule types, deliver modes, command gates, job storage | -| [Providers & Models](docs/providers.md) | 30+ LLM providers, model routing, model_list configuration | -| [Spawn & Async Tasks](docs/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | -| [Hooks](docs/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | -| [Steering](docs/steering.md) | Inject messages into a running agent loop between tool calls | -| [SubTurn](docs/subturn.md) | Subagent coordination, concurrency control, lifecycle | -| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions | -| [Tools Configuration](docs/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills | -| [Hardware Compatibility](docs/hardware-compatibility.md) | Tested boards, minimum requirements | +| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes | +| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides | +| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox | +| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI | +| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage | +| [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration | +| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | +| [Hooks](docs/architecture/hooks/README.md) | Event-driven hook system: observers, interceptors, approval hooks | +| [Steering](docs/architecture/steering.md) | Inject messages into a running agent loop between tool calls | +| [SubTurn](docs/architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle | +| [Troubleshooting](docs/operations/troubleshooting.md) | Common issues and solutions | +| [Tools Configuration](docs/reference/tools_configuration.md) | Per-tool enable/disable, exec policies, MCP, Skills | +| [Hardware Compatibility](docs/guides/hardware-compatibility.md) | Tested boards, minimum requirements | ## 🤝 Contribute & Roadmap diff --git a/assets/wechat.png b/assets/wechat.png index d538f40e6..b368f75d3 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 531cb76aa..10bb3a11c 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -17,24 +17,24 @@ import ( ) const ( - supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" + supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, antigravity" defaultAnthropicModel = "claude-sonnet-4.6" ) -func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { +func authLoginCmd(provider string, useDeviceCode bool, useOauth bool, noBrowser bool) error { switch provider { case "openai": - return authLoginOpenAI(useDeviceCode) + return authLoginOpenAI(useDeviceCode, noBrowser) case "anthropic": return authLoginAnthropic(useOauth) case "google-antigravity", "antigravity": - return authLoginGoogleAntigravity() + return authLoginGoogleAntigravity(noBrowser) default: return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) } } -func authLoginOpenAI(useDeviceCode bool) error { +func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error { cfg := auth.OpenAIOAuthConfig() var cred *auth.AuthCredential @@ -43,7 +43,7 @@ func authLoginOpenAI(useDeviceCode bool) error { if useDeviceCode { cred, err = auth.LoginDeviceCode(cfg) } else { - cred, err = auth.LoginBrowser(cfg) + cred, err = auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser}) } if err != nil { @@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool) error { // Update or add openai in ModelList foundOpenAI := false for i := range appCfg.ModelList { - if isOpenAIModel(appCfg.ModelList[i].Model) { + if isOpenAIModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "oauth" foundOpenAI = true break @@ -92,10 +92,10 @@ func authLoginOpenAI(useDeviceCode bool) error { return nil } -func authLoginGoogleAntigravity() error { +func authLoginGoogleAntigravity(noBrowser bool) error { cfg := auth.GoogleAntigravityOAuthConfig() - cred, err := auth.LoginBrowser(cfg) + cred, err := auth.LoginBrowserWithOptions(cfg, auth.LoginBrowserOptions{NoBrowser: noBrowser}) if err != nil { return fmt.Errorf("login failed: %w", err) } @@ -130,7 +130,7 @@ func authLoginGoogleAntigravity() error { // Update or add antigravity in ModelList foundAntigravity := false for i := range appCfg.ModelList { - if isAntigravityModel(appCfg.ModelList[i].Model) { + if isAntigravityModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "oauth" foundAntigravity = true break @@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error { if err == nil { found := false for i := range appCfg.ModelList { - if isAnthropicModel(appCfg.ModelList[i].Model) { + if isAnthropicModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "oauth" found = true break @@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error { // Update ModelList found := false for i := range appCfg.ModelList { - if isAnthropicModel(appCfg.ModelList[i].Model) { + if isAnthropicModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "token" found = true break @@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error { // Update ModelList found := false for i := range appCfg.ModelList { - if isOpenAIModel(appCfg.ModelList[i].Model) { + if isOpenAIModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "token" found = true break @@ -342,15 +342,15 @@ func authLogoutCmd(provider string) error { for i := range appCfg.ModelList { switch provider { case "openai": - if isOpenAIModel(appCfg.ModelList[i].Model) { + if isOpenAIModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "" } case "anthropic": - if isAnthropicModel(appCfg.ModelList[i].Model) { + if isAnthropicModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "" } case "google-antigravity", "antigravity": - if isAntigravityModel(appCfg.ModelList[i].Model) { + if isAntigravityModel(appCfg.ModelList[i]) { appCfg.ModelList[i].AuthMethod = "" } } @@ -484,22 +484,20 @@ func authModelsCmd() error { return nil } -// isAntigravityModel checks if a model string belongs to antigravity provider -func isAntigravityModel(model string) bool { - return model == "antigravity" || - model == "google-antigravity" || - strings.HasPrefix(model, "antigravity/") || - strings.HasPrefix(model, "google-antigravity/") +// isAntigravityModel checks if a model config belongs to an Antigravity provider. +func isAntigravityModel(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "antigravity" || protocol == "google-antigravity" } -// isOpenAIModel checks if a model string belongs to openai provider -func isOpenAIModel(model string) bool { - return model == "openai" || - strings.HasPrefix(model, "openai/") +// isOpenAIModel checks if a model config belongs to the OpenAI provider. +func isOpenAIModel(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "openai" } -// isAnthropicModel checks if a model string belongs to anthropic provider -func isAnthropicModel(model string) bool { - return model == "anthropic" || - strings.HasPrefix(model, "anthropic/") +// isAnthropicModel checks if a model config belongs to the Anthropic provider. +func isAnthropicModel(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "anthropic" } diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go index afbe098aa..b9b44db34 100644 --- a/cmd/picoclaw/internal/auth/login.go +++ b/cmd/picoclaw/internal/auth/login.go @@ -7,6 +7,7 @@ func newLoginCommand() *cobra.Command { provider string useDeviceCode bool useOauth bool + noBrowser bool ) cmd := &cobra.Command{ @@ -14,12 +15,15 @@ func newLoginCommand() *cobra.Command { Short: "Login via OAuth or paste token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return authLoginCmd(provider, useDeviceCode, useOauth) + return authLoginCmd(provider, useDeviceCode, useOauth, noBrowser) }, } - cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") + cmd.Flags().StringVarP( + &provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, antigravity)", + ) cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not auto-open a browser during OAuth login") cmd.Flags().BoolVar( &useOauth, "setup-token", false, "Use setup-token flow for Anthropic (from `claude setup-token`)", diff --git a/cmd/picoclaw/internal/auth/login_test.go b/cmd/picoclaw/internal/auth/login_test.go index d6a03c25b..5129d9aaf 100644 --- a/cmd/picoclaw/internal/auth/login_test.go +++ b/cmd/picoclaw/internal/auth/login_test.go @@ -18,6 +18,7 @@ func TestNewLoginSubCommand(t *testing.T) { assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("device-code")) + assert.NotNil(t, cmd.Flags().Lookup("no-browser")) providerFlag := cmd.Flags().Lookup("provider") require.NotNil(t, providerFlag) diff --git a/cmd/picoclaw/internal/auth/status_test.go b/cmd/picoclaw/internal/auth/status_test.go index 7748ba502..2f9a70721 100644 --- a/cmd/picoclaw/internal/auth/status_test.go +++ b/cmd/picoclaw/internal/auth/status_test.go @@ -1,12 +1,53 @@ package auth import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + pkgauth "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" ) +func captureAuthStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return buf.String() +} + +func setAuthStatusTestHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestNewStatusSubcommand(t *testing.T) { cmd := newStatusCommand() @@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) { assert.False(t, cmd.HasFlags()) } + +func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) { + tmpDir := setAuthStatusTestHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "legacy-project", + }, + }, + } + data, err := json.Marshal(legacyStore) + require.NoError(t, err) + + authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json") + require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755)) + require.NoError(t, os.WriteFile(authPath, data, 0o600)) + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + ProjectID: "fresh-project", + }) + require.NoError(t, err) + + output := captureAuthStdout(t, func() { + require.NoError(t, authStatusCmd()) + }) + + assert.Contains(t, output, "\nAuthenticated Providers:") + assert.Contains(t, output, "\n google-antigravity:\n") + assert.NotContains(t, output, "\n antigravity:\n") + assert.Contains(t, output, " Project: fresh-project") + assert.Contains(t, output, " Expires: 2026-04-16 12:30") + assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth")) +} diff --git a/cmd/picoclaw/internal/auth/wecom_test.go b/cmd/picoclaw/internal/auth/wecom_test.go index c152481be..aafd39e69 100644 --- a/cmd/picoclaw/internal/auth/wecom_test.go +++ b/cmd/picoclaw/internal/auth/wecom_test.go @@ -3,6 +3,7 @@ package auth import ( "bytes" "context" + "net" "net/http" "net/http/httptest" "net/url" @@ -19,6 +20,19 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + server := httptest.NewUnstartedServer(handler) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + return server +} + func TestNewWeComCommand(t *testing.T) { cmd := newWeComCommand() @@ -53,7 +67,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) { } func TestFetchWeComQRCode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/generate", r.URL.Path) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source")) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID")) @@ -61,7 +75,6 @@ func TestFetchWeComQRCode(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`)) })) - defer server.Close() opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ HTTPClient: server.Client(), @@ -78,7 +91,7 @@ func TestFetchWeComQRCode(t *testing.T) { func TestPollWeComQRCodeResult(t *testing.T) { var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { call := calls.Add(1) assert.Equal(t, "/query", r.URL.Path) assert.Equal(t, "scode-1", r.URL.Query().Get("scode")) @@ -92,7 +105,6 @@ func TestPollWeComQRCodeResult(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`)) } })) - defer server.Close() var output bytes.Buffer opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ diff --git a/cmd/picoclaw/internal/cliui/mcp_show.go b/cmd/picoclaw/internal/cliui/mcp_show.go new file mode 100644 index 000000000..5d5af1e75 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/mcp_show.go @@ -0,0 +1,384 @@ +package cliui + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// MCPShowServer holds the server metadata for PrintMCPShow. +type MCPShowServer struct { + Name string + Type string + Target string + Enabled bool + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global + EnvKeys []string // sorted env var names (values intentionally omitted) + EnvFile string + Headers []string // sorted header names +} + +// MCPShowTool holds one tool's info for PrintMCPShow. +type MCPShowTool struct { + Name string + Description string + Parameters []MCPShowParam +} + +// MCPShowParam is one parameter entry. +type MCPShowParam struct { + Name string + Type string + Description string + Required bool +} + +// PrintMCPShow renders the mcp show output (plain or fancy). +// w is where the output is written; pass cmd.OutOrStdout() from cobra commands. +func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + if !UseFancyLayout() { + printMCPShowPlain(w, server, tools, disabled) + return + } + printMCPShowFancy(w, server, tools, disabled) +} + +// ── plain (narrow / non-TTY) ──────────────────────────────────────────────── + +func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + fmt.Fprintf(w, "Server: %s\n", server.Name) + fmt.Fprintf(w, "Type: %s\n", server.Type) + fmt.Fprintf(w, "Target: %s\n", server.Target) + fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled)) + deferredLabel := boolWord(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredLabel += " (default)" + } + fmt.Fprintf(w, "Deferred: %s\n", deferredLabel) + if len(server.EnvKeys) > 0 { + fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", ")) + } + if server.EnvFile != "" { + fmt.Fprintf(w, "Env file: %s\n", server.EnvFile) + } + if len(server.Headers) > 0 { + fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", ")) + } + fmt.Fprintln(w) + + if disabled { + fmt.Fprintln(w, "Server is disabled; skipping tool discovery.") + return + } + if len(tools) == 0 { + fmt.Fprintln(w, "No tools exposed by this server.") + return + } + + fmt.Fprintf(w, "Tools (%d):\n", len(tools)) + for _, tool := range tools { + fmt.Fprintf(w, " %s\n", tool.Name) + if tool.Description != "" { + fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120)) + } + if len(tool.Parameters) == 0 { + fmt.Fprintln(w, " Parameters: none") + continue + } + for _, p := range tool.Parameters { + line := fmt.Sprintf(" - %s", p.Name) + if p.Type != "" { + line += fmt.Sprintf(" (%s", p.Type) + if p.Required { + line += ", required" + } + line += ")" + } else if p.Required { + line += " (required)" + } + if p.Description != "" { + line += ": " + truncateDescription(p.Description, 80) + } + fmt.Fprintln(w, line) + } + } +} + +// ── fancy (wide TTY) ──────────────────────────────────────────────────────── + +var ( + mcpToolNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) + } + mcpParamNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentRed).Bold(true) + } + mcpTagStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + } + mcpRequiredStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + } + mcpOptionalStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + } + mcpDescStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + } +) + +func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + // ── server header ── + b.WriteString(titleBarStyle().Render("⬡ " + server.Name)) + b.WriteString("\n\n") + + keyW := 10 + writeKV := func(key, val string) { + k := kvKeyStyle().Width(keyW).Render(key) + b.WriteString(k + " " + val + "\n") + } + + writeKV("Type", server.Type) + writeKV("Target", server.Target) + writeKV("Enabled", coloredBool(server.Enabled)) + deferredVal := coloredBool(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredVal += " " + mcpTagStyle().Render("(default)") + } + writeKV("Deferred", deferredVal) + if len(server.EnvKeys) > 0 { + writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", "))) + } + if server.EnvFile != "" { + writeKV("Env file", mutedStyle().Render(server.EnvFile)) + } + if len(server.Headers) > 0 { + writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", "))) + } + + if disabled { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + if len(tools) == 0 { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("No tools exposed by this server.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + // ── tools section ── + b.WriteString("\n") + b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools)))) + b.WriteString("\n") + + contentW := inner - 4 // account for box padding + for i, tool := range tools { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + // Tool name + index badge + badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools))) + b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n") + + // Description (wrapped to content width) + if tool.Description != "" { + desc := truncateDescription(tool.Description, 160) + b.WriteString(" " + mcpDescStyle().Render(desc) + "\n") + } + + // Parameters + if len(tool.Parameters) == 0 { + b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n") + continue + } + + b.WriteString("\n") + for _, p := range tool.Parameters { + // name + pName := mcpParamNameStyle().Render(p.Name) + + // type tag + typeTag := "" + if p.Type != "" { + typeTag = " " + mcpTagStyle().Render("<"+p.Type+">") + } + + // required / optional badge + var reqBadge string + if p.Required { + reqBadge = " " + mcpRequiredStyle().Render("required") + } else { + reqBadge = " " + mcpOptionalStyle().Render("optional") + } + + b.WriteString(" " + pName + typeTag + reqBadge + "\n") + + if p.Description != "" { + desc := truncateDescription(p.Description, 120) + b.WriteString(" " + mutedStyle().Render(desc) + "\n") + } + } + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +// ── mcp list ──────────────────────────────────────────────────────────────── + +// MCPListRow is one row in the mcp list output. +type MCPListRow struct { + Name string + Type string + Target string + Status string // "enabled", "disabled", "ok (N tools)", "error" + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global +} + +// PrintMCPList renders the mcp list output (plain or fancy). +func PrintMCPList(w io.Writer, rows []MCPListRow) { + if !UseFancyLayout() { + printMCPListPlain(w, rows) + return + } + printMCPListFancy(w, rows) +} + +func printMCPListPlain(w io.Writer, rows []MCPListRow) { + headers := []string{"Name", "Type", "Command", "Status", "Deferred"} + tableRows := make([][]string, len(rows)) + for i, r := range rows { + deferred := boolWord(r.EffectiveDeferred) + if !r.DeferredExplicit { + deferred += " (default)" + } + tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred} + } + // reuse the ASCII table renderer already in helpers.go via the caller + // (list.go still uses renderTable for the plain path) + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range tableRows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + border := func() { + fmt.Fprint(w, "+") + for _, width := range widths { + fmt.Fprint(w, strings.Repeat("-", width+2)+"+") + } + fmt.Fprintln(w) + } + writeRow := func(row []string) { + fmt.Fprint(w, "|") + for i, cell := range row { + fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell))) + } + fmt.Fprintln(w) + } + border() + writeRow(headers) + border() + for _, row := range tableRows { + writeRow(row) + } + border() +} + +func printMCPListFancy(w io.Writer, rows []MCPListRow) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + title := fmt.Sprintf("MCP Servers (%d)", len(rows)) + b.WriteString(titleBarStyle().Render(title)) + b.WriteString("\n") + + contentW := inner - 4 + for i, row := range rows { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + statusBadge := mcpListStatusStyle(row.Status).Render(row.Status) + var deferredBadge string + if row.EffectiveDeferred { + if row.DeferredExplicit { + deferredBadge = " " + mcpTagStyle().Render("deferred") + } else { + deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)") + } + } + b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n") + b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n") + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +func mcpListStatusStyle(status string) lipgloss.Style { + switch { + case status == "enabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "disabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + case strings.HasPrefix(status, "ok"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "error": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + default: + return lipgloss.NewStyle() + } +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +func boolWord(v bool) string { + if v { + return "yes" + } + return "no" +} + +func coloredBool(v bool) string { + if v { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes") + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no") +} + +// truncateDescription strips newlines, collapses whitespace, and caps length. +func truncateDescription(s string, maxLen int) string { + // collapse newlines and repeated spaces into a single space + s = strings.Join(strings.Fields(s), " ") + if len(s) <= maxLen { + return s + } + // cut at last space before maxLen + cut := s[:maxLen] + if idx := strings.LastIndex(cut, " "); idx > maxLen/2 { + cut = cut[:idx] + } + return cut + "…" +} diff --git a/cmd/picoclaw/internal/mcp/add.go b/cmd/picoclaw/internal/mcp/add.go new file mode 100644 index 000000000..8ad68571f --- /dev/null +++ b/cmd/picoclaw/internal/mcp/add.go @@ -0,0 +1,249 @@ +package mcp + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type addOptions struct { + Env []string + EnvFile string + Headers []string + Transport string + Force bool + Deferred *bool // nil = not set, true = deferred, false = not deferred +} + +func newAddCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add [flags] [args...]", + Short: "Add or update an MCP server", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, name, target, targetArgs, showHelp, err := parseAddArgs(args) + if showHelp { + return cmd.Help() + } + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + if cfg.Tools.MCP.Servers == nil { + cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig) + } + + if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force { + var overwrite bool + + overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name) + if err != nil { + return fmt.Errorf("failed to confirm overwrite: %w", err) + } + if !overwrite { + return fmt.Errorf("aborted: MCP server %q already exists", name) + } + } + + server, err := buildServerConfig(target, targetArgs, opts) + if err != nil { + return err + } + + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Servers[name] = server + + if err := saveValidatedConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name) + return nil + }, + } + + flags := cmd.Flags() + flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)") + flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)") + flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)") + flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse") + flags.BoolP("force", "f", false, "Overwrite an existing server without prompting") + flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)") + flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)") + + return cmd +} + +func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) { + opts := addOptions{Transport: "stdio"} + var positional []string + serverArgs := make([]string, 0) + explicitCommand := make([]string, 0) + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch { + case arg == "--help" || arg == "-h": + return addOptions{}, "", "", nil, true, nil + case arg == "--": + if i+1 < len(args) { + explicitCommand = append(explicitCommand, args[i+1:]...) + } + i = len(args) + case arg == "--force" || arg == "-f": + opts.Force = true + case arg == "--deferred": + t := true + opts.Deferred = &t + case arg == "--no-deferred": + f := false + opts.Deferred = &f + case arg == "--transport" || arg == "-t": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Transport = args[i] + case strings.HasPrefix(arg, "--transport="): + opts.Transport = strings.TrimPrefix(arg, "--transport=") + case arg == "--env" || arg == "-e": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Env = append(opts.Env, args[i]) + case arg == "--env-file": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.EnvFile = args[i] + case strings.HasPrefix(arg, "--env="): + opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env=")) + case strings.HasPrefix(arg, "--env-file="): + opts.EnvFile = strings.TrimPrefix(arg, "--env-file=") + case arg == "--header" || arg == "-H": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Headers = append(opts.Headers, args[i]) + case strings.HasPrefix(arg, "--header="): + opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header=")) + case strings.HasPrefix(arg, "-") && len(positional) >= 2: + serverArgs = append(serverArgs, args[i:]...) + i = len(args) + default: + positional = append(positional, arg) + } + } + + if len(explicitCommand) > 0 { + if len(positional) != 1 { + return addOptions{}, "", "", nil, false, fmt.Errorf( + "usage: picoclaw mcp add [flags] [args...] or picoclaw mcp add [flags] -- [args...]", + ) + } + if len(explicitCommand) == 0 { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --") + } + return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil + } + + if len(positional) < 2 { + return addOptions{}, "", "", nil, false, fmt.Errorf( + "usage: picoclaw mcp add [flags] [args...] or picoclaw mcp add [flags] -- [args...]", + ) + } + + targetArgs := make([]string, 0, len(positional)-2+len(serverArgs)) + targetArgs = append(targetArgs, positional[2:]...) + targetArgs = append(targetArgs, serverArgs...) + + return opts, positional[0], positional[1], targetArgs, false, nil +} + +func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) { + transport := strings.ToLower(strings.TrimSpace(opts.Transport)) + if transport == "" { + transport = "stdio" + } + switch transport { + case "stdio", "http", "sse": + default: + return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport) + } + + env, err := parseEnvAssignments(opts.Env) + if err != nil { + return config.MCPServerConfig{}, err + } + headers, err := parseHeaderAssignments(opts.Headers) + if err != nil { + return config.MCPServerConfig{}, err + } + + server := config.MCPServerConfig{ + Enabled: true, + Type: transport, + Deferred: opts.Deferred, + } + + switch transport { + case "http", "sse": + if len(env) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport") + } + if strings.TrimSpace(opts.EnvFile) != "" { + return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport") + } + if len(args) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport) + } + parsedURL, err := url.ParseRequestURI(target) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target) + } + server.URL = target + server.Headers = headers + return server, nil + } + + if len(headers) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport") + } + + if looksLikeRemoteURL(target) { + return config.MCPServerConfig{}, fmt.Errorf( + "target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse", + target, + transport, + ) + } + + command := target + commandArgs := append([]string(nil), args...) + + if err := validateLocalCommandPath(target); err != nil { + return config.MCPServerConfig{}, err + } + if isLocalCommandPath(command) { + command = expandHomePath(command) + } + + server.Command = command + server.Args = commandArgs + server.Env = env + server.EnvFile = strings.TrimSpace(opts.EnvFile) + + return server, nil +} diff --git a/cmd/picoclaw/internal/mcp/command.go b/cmd/picoclaw/internal/mcp/command.go new file mode 100644 index 000000000..d6e21181a --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command.go @@ -0,0 +1,25 @@ +package mcp + +import "github.com/spf13/cobra" + +func NewMCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage MCP server configuration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + newAddCommand(), + newRemoveCommand(), + newListCommand(), + newEditCommand(), + newTestCommand(), + newShowCommand(), + ) + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/command_test.go b/cmd/picoclaw/internal/mcp/command_test.go new file mode 100644 index 000000000..be1c9763e --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command_test.go @@ -0,0 +1,619 @@ +package mcp + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewMCPCommand(t *testing.T) { + cmd := NewMCPCommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "mcp", cmd.Use) + assert.Equal(t, "Manage MCP server configuration", cmd.Short) + assert.True(t, cmd.HasSubCommands()) + + allowedCommands := []string{ + "add", + "remove", + "list", + "edit", + "test", + "show", + } + + subcommands := cmd.Commands() + assert.Len(t, subcommands, len(allowedCommands)) + + for _, subcmd := range subcommands { + found := slices.Contains(allowedCommands, subcmd.Name()) + assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) + assert.False(t, subcmd.Hidden) + } +} + +func TestMCPAddAddsGenericStdioServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{ + "add", + "sqlite", + "npx", + "-y", + "@modelcontextprotocol/server-sqlite", + "--db", + "./mydb.db", + }, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "sqlite" saved`) + + cfg := readMCPConfig(t, configPath) + require.True(t, cfg.Tools.MCP.Enabled) + + server, ok := cfg.Tools.MCP.Servers["sqlite"] + require.True(t, ok) + assert.True(t, server.Enabled) + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args) +} + +func TestMCPAddSupportsHeadersAfterURL(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "apify", + "https://mcp.apify.com/", + "-t", + "http", + "--header", + "Authorization: Bearer OMITTED", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["apify"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.apify.com/", server.URL) + assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers) +} + +func TestMCPAddSupportsTransportBeforeName(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "sse", + "fiscal-ai", + "https://api.fiscal.ai/mcp/sse", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["fiscal-ai"] + assert.Equal(t, "sse", server.Type) + assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL) +} + +func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "stdio", + "--env", + "AIRTABLE_API_KEY=YOUR_KEY", + "airtable", + "--", + "npx", + "-y", + "airtable-mcp-server", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["airtable"] + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args) + assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env) +} + +func TestMCPAddSupportsEnvFileForStdio(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--env-file", + ".env.mcp", + "filesystem", + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["filesystem"] + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args) + assert.Equal(t, ".env.mcp", server.EnvFile) +} + +func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) { + setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "http", + "--env-file", + ".env.mcp", + "context7", + "https://mcp.context7.com/mcp", + }, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport") +} + +func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) { + setupMCPConfigEnv(t) + + tmpDir := t.TempDir() + localCmd := filepath.Join(tmpDir, "server.sh") + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644)) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local", localCmd}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "not executable") +} + +func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + localCmd := filepath.Join(homeDir, "bin", "my-mcp") + require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755)) + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + + tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["local-home"] + assert.Equal(t, localCmd, server.Command) +} + +func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) { + setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `looks like a remote MCP URL`) + assert.Contains(t, err.Error(), `Use --transport http or --transport sse`) +} + +func TestMCPAddOverwritePromptDecline(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n") + require.Error(t, err) + assert.Contains(t, output, `Overwrite? [y/N]:`) + assert.Contains(t, err.Error(), "aborted") + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddOverwriteWithConfirmation(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddHTTPServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "context7", + "--transport", + "http", + "https://mcp.context7.com/mcp", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["context7"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.context7.com/mcp", server.URL) + assert.Empty(t, server.Command) +} + +func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" removed`) + + cfg := readMCPConfig(t, configPath) + assert.False(t, cfg.Tools.MCP.Enabled) + assert.Empty(t, cfg.Tools.MCP.Servers) +} + +func TestMCPListPrintsTable(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "context7": { + Enabled: true, + Type: "http", + URL: "https://mcp.context7.com/mcp", + }, + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list"}, "") + require.NoError(t, err) + assert.Contains(t, output, "| Name") + assert.Contains(t, output, "context7") + assert.Contains(t, output, "filesystem") + assert.Contains(t, output, "https://mcp.context7.com/mcp") + assert.Contains(t, output, "disabled") +} + +func TestMCPListWithStatusUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + assert.Equal(t, "npx", server.Command) + return probeResult{ToolCount: 3}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list", "--status"}, "") + require.NoError(t, err) + assert.Contains(t, output, "ok (3 tools)") +} + +func TestMCPEditUsesEditor(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + originalEditor := editorCommand + defer func() { editorCommand = originalEditor }() + + var gotName string + var gotArgs []string + editorCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return exec.Command("sh", "-c", "exit 0") + } + + t.Setenv("EDITOR", `dummy-editor --wait`) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.NoError(t, err) + + assert.Equal(t, "dummy-editor", gotName) + assert.Equal(t, []string{"--wait", configPath}, gotArgs) + _, statErr := os.Stat(configPath) + assert.NoError(t, statErr) +} + +func TestMCPEditRequiresEditor(t *testing.T) { + setupMCPConfigEnv(t) + t.Setenv("EDITOR", "") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "$EDITOR is not set") +} + +func TestMCPTestUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + return probeResult{ToolCount: 2}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"test", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`) +} + +func TestMCPAddDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.True(t, *server.Deferred) +} + +func TestMCPAddNoDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.False(t, *server.Deferred) +} + +func TestMCPAddNoDeferredByDefault(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + assert.Nil(t, server.Deferred) +} + +func TestMCPShowNotFound(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, nil) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"show", "missing"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `"missing" not found`) +} + +func TestMCPShowDisabledServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "disabled") +} + +func TestMCPShowUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + original := serverShowProbe + defer func() { serverShowProbe = original }() + serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) { + assert.Equal(t, "myserver", name) + return []toolDetail{ + { + Name: "read_file", + Description: "Read a file from the filesystem", + Parameters: []paramDetail{ + {Name: "path", Type: "string", Description: "File path", Required: true}, + {Name: "encoding", Type: "string", Description: "Character encoding", Required: false}, + }, + }, + { + Name: "list_dir", + Description: "List directory contents", + Parameters: nil, + }, + }, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "read_file") + assert.Contains(t, output, "Read a file from the filesystem") + assert.Contains(t, output, "path") + assert.Contains(t, output, "string") + assert.Contains(t, output, "required") + assert.Contains(t, output, "list_dir") + assert.Contains(t, output, "none") +} + +func setupMCPConfigEnv(t *testing.T) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv(config.EnvConfig, configPath) + t.Setenv(config.EnvHome, filepath.Dir(configPath)) + return configPath +} + +func writeMCPConfig(t *testing.T, path string, cfg *config.Config) { + t.Helper() + + if cfg == nil { + cfg = config.DefaultConfig() + } + + require.NoError(t, config.SaveConfig(path, cfg)) +} + +func readMCPConfig(t *testing.T, path string) *config.Config { + t.Helper() + + cfg, err := config.LoadConfig(path) + require.NoError(t, err) + return cfg +} + +func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.SetArgs(args) + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetIn(strings.NewReader(stdin)) + + err := cmd.Execute() + return stdout.String() + stderr.String(), err +} diff --git a/cmd/picoclaw/internal/mcp/edit.go b/cmd/picoclaw/internal/mcp/edit.go new file mode 100644 index 000000000..06dcb6aef --- /dev/null +++ b/cmd/picoclaw/internal/mcp/edit.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "go.mau.fi/util/shlex" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" +) + +func newEditCommand() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open the PicoClaw config in $EDITOR", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + return fmt.Errorf("$EDITOR is not set") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + if err = saveValidatedConfig(cfg); err != nil { + return err + } + + editorArgs, err := shlex.Split(editor) + if err != nil { + return fmt.Errorf("failed to parse $EDITOR: %w", err) + } + if len(editorArgs) == 0 { + return fmt.Errorf("$EDITOR is empty") + } + + editorArgs = append(editorArgs, internal.GetConfigPath()) + process := editorCommand(editorArgs[0], editorArgs[1:]...) + process.Stdin = cmd.InOrStdin() + process.Stdout = cmd.OutOrStdout() + process.Stderr = cmd.ErrOrStderr() + + if err := process.Run(); err != nil { + return fmt.Errorf("failed to start editor: %w", err) + } + + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/helpers.go b/cmd/picoclaw/internal/mcp/helpers.go new file mode 100644 index 000000000..0fb0b245c --- /dev/null +++ b/cmd/picoclaw/internal/mcp/helpers.go @@ -0,0 +1,359 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type probeResult struct { + ToolCount int +} + +var ( + editorCommand = exec.Command + serverProbe = defaultServerProbe + + mcpConfigSchemaOnce sync.Once + mcpConfigSchema *jsonschema.Resolved + errMcpConfigSchema error +) + +const mcpConfigSchemaJSON = `{ + "type": "object", + "properties": { + "tools": { + "type": "object", + "properties": { + "mcp": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "discovery": { "type": "object", "additionalProperties": true }, + "max_inline_text_chars": { "type": "integer" }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "deferred": { "type": "boolean" }, + "command": { "type": "string" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "env_file": { "type": "string" }, + "type": { + "type": "string", + "enum": ["stdio", "http", "sse"] + }, + "url": { "type": "string" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["enabled"], + "anyOf": [ + { "required": ["command"] }, + { "required": ["url"] } + ], + "additionalProperties": false + } + } + }, + "required": ["enabled"], + "additionalProperties": true + } + }, + "required": ["mcp"], + "additionalProperties": true + } + }, + "required": ["tools"], + "additionalProperties": true +}` + +func loadConfig() (*config.Config, error) { + cfg, err := config.LoadConfig(internal.GetConfigPath()) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return cfg, nil +} + +func saveValidatedConfig(cfg *config.Config) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + + data, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to serialize config: %w", err) + } + + if err := validateConfigDocument(data); err != nil { + return err + } + + if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + return nil +} + +func validateConfigDocument(data []byte) error { + var instance map[string]any + if err := json.Unmarshal(data, &instance); err != nil { + return fmt.Errorf("failed to decode serialized config: %w", err) + } + + schema, err := loadMCPConfigSchema() + if err != nil { + return fmt.Errorf("failed to load MCP config schema: %w", err) + } + + if err := schema.Validate(instance); err != nil { + return fmt.Errorf("config validation failed: %w", err) + } + + return nil +} + +func loadMCPConfigSchema() (*jsonschema.Resolved, error) { + mcpConfigSchemaOnce.Do(func() { + var schema jsonschema.Schema + if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil { + errMcpConfigSchema = err + return + } + mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil) + }) + + return mcpConfigSchema, errMcpConfigSchema +} + +func inferTransportType(server config.MCPServerConfig) string { + switch server.Type { + case "stdio", "http", "sse": + return server.Type + } + if server.URL != "" { + return "sse" + } + if server.Command != "" { + return "stdio" + } + return "unknown" +} + +func renderServerTarget(server config.MCPServerConfig) string { + transport := inferTransportType(server) + if transport == "http" || transport == "sse" { + if server.URL == "" { + return "" + } + return server.URL + } + + parts := append([]string{server.Command}, server.Args...) + rendered := strings.TrimSpace(strings.Join(parts, " ")) + if rendered == "" { + return "" + } + return rendered +} + +func sortedServerNames(servers map[string]config.MCPServerConfig) []string { + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func parseEnvAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + env := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, "=") + if !found { + return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry) + } + env[key] = value + } + + return env, nil +} + +func parseHeaderAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + headers := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, ":") + if !found { + key, value, found = strings.Cut(entry, "=") + } + if !found { + return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry) + } + headers[key] = value + } + + return headers, nil +} + +func looksLikeRemoteURL(target string) bool { + parsedURL, err := url.ParseRequestURI(target) + if err != nil { + return false + } + if parsedURL.Host == "" { + return false + } + switch strings.ToLower(parsedURL.Scheme) { + case "http", "https": + return true + default: + return false + } +} + +func isLocalCommandPath(command string) bool { + if command == "" { + return false + } + if looksLikeRemoteURL(command) { + return false + } + return filepath.IsAbs(command) || + filepath.VolumeName(command) != "" || + strings.HasPrefix(command, "."+string(os.PathSeparator)) || + strings.HasPrefix(command, ".."+string(os.PathSeparator)) || + command == "." || + command == ".." || + strings.ContainsRune(command, os.PathSeparator) +} + +func expandHomePath(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") { + return filepath.Join(home, path[2:]) + } + return path +} + +func validateLocalCommandPath(command string) error { + if !isLocalCommandPath(command) { + return nil + } + + path := expandHomePath(command) + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("local command %q does not exist", command) + } + return fmt.Errorf("failed to stat local command %q: %w", command, err) + } + if info.IsDir() { + return fmt.Errorf("local command %q is a directory", command) + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + return fmt.Errorf("local command %q is not executable", command) + } + return nil +} + +func defaultServerProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) (probeResult, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return probeResult{}, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return probeResult{}, fmt.Errorf("server %q did not register a connection", name) + } + + return probeResult{ToolCount: len(conn.Tools)}, nil +} + +func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) { + if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil { + return false, err + } + + var answer string + if _, err := fmt.Fscanln(r, &answer); err != nil { + if errors.Is(err, io.EOF) { + return false, nil + } + return false, err + } + + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/picoclaw/internal/mcp/list.go b/cmd/picoclaw/internal/mcp/list.go new file mode 100644 index 000000000..f95fcf65d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/list.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" +) + +func newListCommand() *cobra.Command { + var ( + includeStatus bool + timeout time.Duration + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List configured MCP servers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + if len(cfg.Tools.MCP.Servers) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.") + return nil + } + + rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers)) + for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) { + server := cfg.Tools.MCP.Servers[name] + status := "disabled" + if server.Enabled { + status = "enabled" + } + + if includeStatus && server.Enabled { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath()) + cancel() + if probeErr != nil { + status = "error" + } else { + status = fmt.Sprintf("ok (%d tools)", result.ToolCount) + } + } + + effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + + rows = append(rows, cliui.MCPListRow{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Status: status, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + }) + } + + cliui.PrintMCPList(cmd.OutOrStdout(), rows) + return nil + }, + } + + cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status") + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check") + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/remove.go b/cmd/picoclaw/internal/mcp/remove.go new file mode 100644 index 000000000..d82af941d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/remove.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newRemoveCommand() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an MCP server from config", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + if _, exists := cfg.Tools.MCP.Servers[name]; !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + delete(cfg.Tools.MCP.Servers, name) + if len(cfg.Tools.MCP.Servers) == 0 { + cfg.Tools.MCP.Servers = nil + cfg.Tools.MCP.Enabled = false + } + + if err := saveValidatedConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name) + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/show.go b/cmd/picoclaw/internal/mcp/show.go new file mode 100644 index 000000000..65953c2da --- /dev/null +++ b/cmd/picoclaw/internal/mcp/show.go @@ -0,0 +1,237 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type toolDetail struct { + Name string + Description string + Parameters []paramDetail +} + +type paramDetail struct { + Name string + Type string + Description string + Required bool +} + +var serverShowProbe = defaultServerShowProbe + +func defaultServerShowProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) ([]toolDetail, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return nil, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return nil, fmt.Errorf("server %q did not register a connection", name) + } + + details := make([]toolDetail, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + details = append(details, toolDetail{ + Name: tool.Name, + Description: tool.Description, + Parameters: extractParameters(tool.InputSchema), + }) + } + return details, nil +} + +func extractParameters(schema any) []paramDetail { + schemaMap := normalizeSchema(schema) + properties, ok := schemaMap["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return nil + } + + required := make(map[string]struct{}) + switch raw := schemaMap["required"].(type) { + case []string: + for _, name := range raw { + required[name] = struct{}{} + } + case []any: + for _, value := range raw { + if name, ok := value.(string); ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]paramDetail, 0, len(names)) + for _, name := range names { + param := paramDetail{Name: name} + if propMap, ok := properties[name].(map[string]any); ok { + if typeName, ok := propMap["type"].(string); ok { + param.Type = strings.TrimSpace(typeName) + } + if desc, ok := propMap["description"].(string); ok { + param.Description = strings.TrimSpace(desc) + } + } + _, param.Required = required[name] + params = append(params, param) + } + return params +} + +func normalizeSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{} + } + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + var jsonData []byte + switch raw := schema.(type) { + case json.RawMessage: + jsonData = raw + case []byte: + jsonData = raw + default: + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{} + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{} + } + return result +} + +func newShowCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details and tools for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled) + + if !server.Enabled { + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to connect to MCP server %q: %w", name, err) + } + + tools := make([]cliui.MCPShowTool, 0, len(details)) + for _, d := range details { + params := make([]cliui.MCPShowParam, 0, len(d.Parameters)) + for _, p := range d.Parameters { + params = append(params, cliui.MCPShowParam{ + Name: p.Name, + Type: p.Type, + Description: p.Description, + Required: p.Required, + }) + } + tools = append(tools, cliui.MCPShowTool{ + Name: d.Name, + Description: d.Description, + Parameters: params, + }) + } + + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout") + + return cmd +} + +func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer { + effectiveDeferred := discoveryEnabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + info := cliui.MCPShowServer{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Enabled: server.Enabled, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + EnvFile: server.EnvFile, + } + if len(server.Env) > 0 { + keys := make([]string, 0, len(server.Env)) + for k := range server.Env { + keys = append(keys, k) + } + sort.Strings(keys) + info.EnvKeys = keys + } + if len(server.Headers) > 0 { + keys := make([]string, 0, len(server.Headers)) + for k := range server.Headers { + keys = append(keys, k) + } + sort.Strings(keys) + info.Headers = keys + } + return info +} diff --git a/cmd/picoclaw/internal/mcp/test.go b/cmd/picoclaw/internal/mcp/test.go new file mode 100644 index 000000000..101cfee65 --- /dev/null +++ b/cmd/picoclaw/internal/mcp/test.go @@ -0,0 +1,46 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +func newTestCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "test ", + Short: "Test connectivity for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + result, err := serverProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to reach MCP server %q: %w", name, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout") + + return cmd +} diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index 4be19b2a5..3f0ff0d8d 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 cp -r ../../../../workspace . +//go:generate go run ../../../../scripts/copydir.go "${DOLLAR}{codespace}/workspace" ./workspace //go:embed workspace var embeddedFiles embed.FS diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index e8e4fee9a..f80b1f9c7 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -3,12 +3,12 @@ package status import ( "fmt" "os" - "strings" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) func statusCmd() { @@ -44,12 +44,13 @@ func statusCmd() { // not depend on a legacy cfg.Providers field (which may not exist under some // build tags). We infer provider availability from model_list entries. hasProtocolKey := func(protocol string) bool { - prefix := protocol + "/" + want := providers.NormalizeProvider(protocol) for _, m := range cfg.ModelList { if m == nil { continue } - if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" { + got, _ := providers.ExtractProtocol(m) + if got == want && m.APIKey() != "" { return true } } @@ -67,12 +68,13 @@ func statusCmd() { return "", false } findProtocolBase := func(protocol string) (string, bool) { - prefix := protocol + "/" + want := providers.NormalizeProvider(protocol) for _, m := range cfg.ModelList { if m == nil { continue } - if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" { + got, _ := providers.ExtractProtocol(m) + if got == want && m.APIBase != "" { return m.APIBase, true } } diff --git a/cmd/picoclaw/internal/status/helpers_test.go b/cmd/picoclaw/internal/status/helpers_test.go new file mode 100644 index 000000000..f037b6bfa --- /dev/null +++ b/cmd/picoclaw/internal/status/helpers_test.go @@ -0,0 +1,89 @@ +package status + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + os.Stdout = w + + fn() + + _ = w.Close() + os.Stdout = oldStdout + defer r.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy() error = %v", err) + } + return buf.String() +} + +func TestStatusCmd_RecognizesProviderFieldWithoutModelPrefix(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + workspace := filepath.Join(tmpDir, "workspace") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + + t.Setenv(config.EnvConfig, configPath) + t.Setenv(config.EnvHome, tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ModelName: "gpt-5.4", + Workspace: workspace, + Provider: "openai", + MaxTokens: 65536, + Temperature: nil, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", + APIBase: "https://api.openai.com/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + Enabled: true, + }, + { + ModelName: "qwen-plus", + Provider: "qwen", + Model: "qwen-plus", + APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", + APIKeys: config.SimpleSecureStrings("test-key"), + Enabled: true, + }, + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("config.SaveConfig() error = %v", err) + } + + output := captureStdout(t, statusCmd) + + if !strings.Contains(output, "OpenAI API: \u2713") { + t.Fatalf("status output missing OpenAI provider: %s", output) + } + if !strings.Contains(output, "Qwen API: \u2713") { + t.Fatalf("status output missing Qwen provider: %s", output) + } +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 0867203a6..abcf03a34 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" @@ -87,6 +88,7 @@ picoclaw --no-color status`, gateway.NewGatewayCommand(), status.NewStatusCommand(), cron.NewCronCommand(), + mcp.NewMCPCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), model.NewModelCommand(), diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 309e60ba9..037c7c2e6 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) { "auth", "cron", "gateway", + "mcp", "migrate", "model", "onboard", diff --git a/config/config.example.json b/config/config.example.json index 858472488..30460c231 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -13,7 +13,8 @@ "split_on_marker": false, "tool_feedback": { "enabled": false, - "max_args_length": 300 + "max_args_length": 300, + "separate_messages": false } } }, diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b6fc724b5..6fafb5150 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js exit 0 fi +# Remove stale PID file from a previous container run. +# After docker kill / OOM / crash the PID file may linger on the bind-mounted +# volume and block the next gateway start (the recorded PID could collide with +# an unrelated process inside the new container). +rm -f "${HOME}/.picoclaw/.picoclaw.pid" + exec picoclaw gateway "$@" diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..529eb49ec --- /dev/null +++ b/docs/README.md @@ -0,0 +1,132 @@ +# PicoClaw Documentation + +PicoClaw documentation is organized by document type first and language second. + +This file describes the recommended documentation layout, how translated files should be named, and what `make lint-docs` currently checks locally. + +These conventions are intended as contributor guidance for new or moved docs. Existing docs may still have historical exceptions, and `make lint-docs` only checks a common subset of the patterns described here. + +## Reader Navigation + +If you are browsing docs rather than reorganizing them, start with these directory indexes: + +- [Guides](guides/README.md): setup, configuration, provider, and workflow guides. +- [Reference](reference/README.md): precise configuration and behavior reference. +- [Operations](operations/README.md): debugging and troubleshooting material. +- [Security](security/README.md): security-focused guides and controls. +- [Architecture](architecture/README.md): implementation notes and internal design docs. +- [Migration](migration/README.md): upgrade and migration notes. + +For channel-specific setup, start with [Chat Apps Configuration](guides/chat-apps.md) and then drill into `docs/channels//README.md` as needed. + +## Principles + +- Choose the document type directory first. Do not create language buckets such as `docs/zh/` or `docs/fr/`. +- Keep each translated document next to its English source document. +- Use English as the base filename with no locale suffix. +- Use lowercase locale suffixes for translations, for example `configuration.zh.md` or `README.pt-br.md`. +- Keep module-specific docs next to the code they describe instead of moving them into `docs/`. + +## Recommended Directories + +- `README.md`: English project entry document at the repository root. +- `docs/project/`: translated project entry documents such as `README.zh.md` and `CONTRIBUTING.zh.md`. +- `docs/guides/`: setup and usage guides. +- `docs/reference/`: reference material and detailed configuration docs. +- `docs/operations/`: debugging and troubleshooting docs. +- `docs/security/`: security-related documentation. +- `docs/architecture/`: architecture and internal design notes. +- `docs/channels/`: channel-specific integration guides. +- `docs/design/`: design proposals and investigations. +- `docs/migration/`: migration notes. + +## Recommended Naming + +- English documents use the base filename: + - `README.md` + - `configuration.md` +- Translations use `..md`: + - `README.zh.md` + - `configuration.fr.md` + - `README.pt-br.md` +- Code-adjacent translated READMEs follow the same rule: + - `pkg/audio/asr/README.zh.md` + - `pkg/isolation/README.zh.md` + +## Common Patterns To Avoid + +- Root-level translated entry docs such as `README.zh.md` or `CONTRIBUTING.fr.md` + - Use `docs/project/README.zh.md` or `docs/project/CONTRIBUTING.fr.md` instead. +- Language directories under `docs/` such as `docs/zh/`, `docs/ZH/`, `docs/ja/`, or `docs/fr/` + - Use `docs//..md` instead. +- Nested locale buckets such as `docs/guides/zh/configuration.md` or `docs/channels/telegram/zh/README.md` + - Keep translations beside the English source file instead. +- Legacy translation filenames such as `README_zh.md` or `README_CN.md` + - Use `README.zh.md`. +- Non-canonical locale suffixes such as `configuration_zh.md` or `configuration.ZH.md` + - Use lowercase `..md`, for example `configuration.zh.md`. + +## Translation Placement + +- For docs under `docs/guides`, `docs/reference`, `docs/operations`, `docs/security`, `docs/architecture`, `docs/channels`, and `docs/migration`, keep translations beside the English source file. +- For project entry translations, keep translated files in `docs/project/` and keep the English source in the repository root. +- In most cases, each translated file should have an English source document: + - `docs/guides/configuration.zh.md` usually sits beside `docs/guides/configuration.md` + - `docs/project/README.zh.md` usually corresponds to `README.md` +- Exception: `docs/design/` may contain locale-specific working notes without an English source document. The naming rules still apply there. + +## Code-Adjacent Docs + +Keep documentation next to the implementation when it primarily describes a package, command, example, or subproject. + +Examples: + +- `pkg/**/README.md` +- `cmd/**/README.md` +- `web/README.md` +- `examples/**/README.md` + +These files still follow the same translation naming rules. + +## Adding a New Document + +1. Pick the correct document type directory. +2. Create the English source file first. +3. Add translated siblings after the English source exists when that source is part of the same docs set. +4. Update links from existing docs when the new doc becomes a navigation target. +5. Run `make lint-docs` locally when adding or moving docs. + +## Examples + +- New setup guide: + - `docs/guides/launcher-setup.md` + - `docs/guides/launcher-setup.zh.md` +- New security guide: + - `docs/security/token-rotation.md` +- New translated package README: + - `pkg/channels/README.zh.md` + +## Validation + +Run: + +```bash +make lint-docs +``` + +The local docs linter currently checks these common cases: + +- no root-level translated `README` or `CONTRIBUTING` files +- no `docs//` language buckets, regardless of case +- no nested locale buckets under typed docs directories +- no legacy `README_*.md` filenames +- no non-canonical translation-like filenames such as `_zh.md` or `.ZH.md` +- no extra Markdown files directly under `docs/` except `docs/README.md` +- every translated Markdown file has a matching English source file + - except for locale-specific working notes under `docs/design/` + +`make lint-docs` is a local consistency check for common naming and placement mistakes. It helps contributors stay close to the recommended layout, but it is not intended to describe every acceptable documentation pattern in the repository. + +When a check fails, `make lint-docs` prints the failing path, the reason, and a suggested fix. + +If you change these recommendations or want the local linter to reflect them more closely, update this file and `scripts/lint-docs.sh` together. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..6df7447a7 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,12 @@ +# Architecture + +Internal architecture notes for major runtime mechanisms and subsystem design. + +- [Steering](steering.md): injecting messages into a running agent loop between tool calls. +- [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)) +- [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. + +For proposal-style or exploratory docs, also see [`../design/`](../design/). diff --git a/docs/agent-refactor/README.md b/docs/architecture/agent-refactor/README.md similarity index 100% rename from docs/agent-refactor/README.md rename to docs/architecture/agent-refactor/README.md diff --git a/docs/architecture/agent-refactor/agent-rename-plan.md b/docs/architecture/agent-refactor/agent-rename-plan.md new file mode 100644 index 000000000..f4ab408fe --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.md @@ -0,0 +1,100 @@ +# Agent File Rename Plan + +## Goal + +Unify `pkg/agent/` package file naming to resolve the `loop_*` prefix naming confusion and unclear responsibility boundaries. + +## Change Overview + +### File Renames (12 files) + +| Original | New | Description | +|----------|-----|-------------| +| `loop.go` | `agent.go` | AgentLoop main body + lifecycle methods | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_event.go` | `agent_event.go` | Event system | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_steering.go` | `agent_steering.go` | Steering message handling | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_media.go` | `agent_media.go` | Media processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP initialization | +| `loop_utils.go` | `agent_utils.go` | Utility functions | +| `loop_inject.go` | `agent_inject.go` | Dependency injection | +| `loop_turn.go` | `turn_coord.go` | Turn coordinator | + +### File Merges (2 → 1) + +| Original | New | Description | +|----------|-----|-------------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn-related type definitions | + +## Final File Structure + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close lifecycle +├── agent_message.go # Message processing +├── agent_outbound.go # Response publishing +├── agent_event.go # Event system +├── agent_command.go # Command processing +├── agent_steering.go # Steering +├── agent_transcribe.go # Transcription +├── agent_media.go # Media processing +├── agent_mcp.go # MCP +├── agent_utils.go # Utility functions +├── agent_inject.go # Dependency injection +├── turn_coord.go # runTurn + coordinator +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## Naming Convention + +| Prefix | Content | Example | +|--------|---------|---------| +| `agent_*` | AgentLoop method files | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn lifecycle related | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline methods | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | Context management | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook system | `hook_process.go`, `hook_mount.go` | + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - Message loop Run/Stop/Close │ +│ - Dependency injection (agent_inject.go) │ +│ - Message routing (agent_message.go) │ +│ - Response publishing (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): main coordinator │ +│ - abortTurn(): abort │ +│ - askSideQuestion(): side question │ +│ - selectCandidates(): model selection │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): initialization │ +│ - CallLLM(): LLM call │ +│ - ExecuteTools(): tool execution │ +│ - Finalize(): finalization │ +└─────────────────────────────────────────────────────────┘ +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/agent-rename-plan.zh.md b/docs/architecture/agent-refactor/agent-rename-plan.zh.md new file mode 100644 index 000000000..938817e10 --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.zh.md @@ -0,0 +1,100 @@ +# Agent 文件重命名计划 + +## 目标 + +统一 `pkg/agent/` 包的文件命名,解决 `loop_*` 前缀命名混乱、职责边界不清晰的问题。 + +## 变更概览 + +### 文件重命名(12 个) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `loop.go` | `agent.go` | AgentLoop 主体 + 生命周期方法 | +| `loop_message.go` | `agent_message.go` | 消息处理和路由 | +| `loop_outbound.go` | `agent_outbound.go` | 响应发布 | +| `loop_event.go` | `agent_event.go` | 事件系统 | +| `loop_command.go` | `agent_command.go` | 命令处理 | +| `loop_steering.go` | `agent_steering.go` | Steering 消息处理 | +| `loop_transcribe.go` | `agent_transcribe.go` | 音频转录 | +| `loop_media.go` | `agent_media.go` | 媒体处理 | +| `loop_mcp.go` | `agent_mcp.go` | MCP 初始化 | +| `loop_utils.go` | `agent_utils.go` | 工具函数 | +| `loop_inject.go` | `agent_inject.go` | 依赖注入 | +| `loop_turn.go` | `turn_coord.go` | Turn 协调器 | + +### 文件合并(2 → 1) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn 相关类型定义 | + +## 最终文件结构 + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close 生命周期 +├── agent_message.go # 消息处理 +├── agent_outbound.go # 响应发布 +├── agent_event.go # 事件系统 +├── agent_command.go # 命令处理 +├── agent_steering.go # Steering +├── agent_transcribe.go # 转录 +├── agent_media.go # 媒体处理 +├── agent_mcp.go # MCP +├── agent_utils.go # 工具函数 +├── agent_inject.go # 依赖注入 +├── turn_coord.go # runTurn + 协调器 +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## 命名约定 + +| 前缀 | 内容 | 示例 | +|------|------|------| +| `agent_*` | AgentLoop 的方法文件 | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn 生命周期相关 | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline 方法 | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | 上下文管理 | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook 系统 | `hook_process.go`, `hook_mount.go` | + +## 架构层次 + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - 消息循环 Run/Stop/Close │ +│ - 依赖注入 (agent_inject.go) │ +│ - 消息路由 (agent_message.go) │ +│ - 响应发布 (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): 主协调器 │ +│ - abortTurn(): 中止 │ +│ - askSideQuestion(): 侧问 │ +│ - selectCandidates(): 模型选择 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): 初始化 │ +│ - CallLLM(): LLM 调用 │ +│ - ExecuteTools(): 工具执行 │ +│ - Finalize(): 终结 │ +└─────────────────────────────────────────────────────────┘ +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/agent-refactor/context.md b/docs/architecture/agent-refactor/context.md similarity index 100% rename from docs/agent-refactor/context.md rename to docs/architecture/agent-refactor/context.md diff --git a/docs/architecture/agent-refactor/loop-split.md b/docs/architecture/agent-refactor/loop-split.md new file mode 100644 index 000000000..5395baeeb --- /dev/null +++ b/docs/architecture/agent-refactor/loop-split.md @@ -0,0 +1,77 @@ +# AgentLoop File Split + +> **Note:** This document describes the file split that was completed in a previous phase. The `loop_*` naming has since been renamed to `agent_*` and `turn_*`. See [agent-rename-plan.md](./agent-rename-plan.md) for the current file structure. + +## Overview + +The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focused source files. This is a pure refactoring with no behavioral changes. + +## Goals + +- Reduce cognitive load when navigating agent loop code +- Enable parallel work by decoupling concerns +- Maintain all existing functionality and tests +- Keep imports minimal per file + +## Original File Map (Renamed in Phase 2) + +| Old File | New File | Responsibility | +|----------|----------|----------------| +| `loop.go` | `agent.go` | Core `AgentLoop` struct, `Run`, `Stop`, `Close` | +| `loop_turn.go` | `turn_coord.go` + `pipeline_*.go` | Turn execution: coordinator + Pipeline methods | +| `loop_utils.go` | `agent_utils.go` | Standalone utility functions | +| `loop_init.go` | `agent_init.go` | `NewAgentLoop` constructor and tool registration | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP runtime | +| `loop_event.go` | `agent_event.go` | Event system helpers | +| `loop_media.go` | `agent_media.go` | Media resolution | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_steering.go` | `agent_steering.go` | Steering queue | +| `loop_inject.go` | `agent_inject.go` | Setter injection | + +## Current File Structure + +See [agent-rename-plan.md](./agent-rename-plan.md) for the complete current file structure. + +## Phase 2: Rename and Pipeline Restructuring + +Phase 2 completed the following: + +1. **File renaming**: All `loop_*` files renamed to `agent_*` or `turn_*` +2. **Turn state merging**: `turn.go` + `turn_exec.go` → `turn_state.go` +3. **Pipeline extraction**: Split large `runTurn` into Pipeline methods + +### Pipeline Architecture + +The Pipeline methods provide structured turn execution: + +| Method | File | Responsibility | +|--------|------|----------------| +| `SetupTurn()` | `pipeline_setup.go` | History assembly, message building, candidate selection | +| `CallLLM()` | `pipeline_llm.go` | PreLLM hooks, fallback, retry, AfterLLM hooks | +| `ExecuteTools()` | `pipeline_execute.go` | Tool execution with hooks | +| `Finalize()` | `pipeline_finalize.go` | Session persistence, compression | + +## Core Principles Applied + +### 1. Same Package, Independent Files +All files belong to the `agent` package and compile together. This preserves the original visibility rules. + +### 2. No Logic Changes +All functions were moved verbatim. The extraction preserved behavioral equivalence. + +### 3. Shared Types in turn_state.go +The `turnState`, `turnExecution`, `Control`, `ToolControl`, and `LLMPhase` types are centralized in `turn_state.go`. + +## Testing + +All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor. + +Build status: `go build ./pkg/agent/...` passes with no errors. + +## See Also + +- [agent-rename-plan.md](./agent-rename-plan.md) — Current file naming convention +- [context.md](context.md) — context management and session handling diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md new file mode 100644 index 000000000..b77987af1 --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md @@ -0,0 +1,68 @@ +# Pipeline Restructuring Plan + +## Goal + +Split `agent/pipeline.go` (~1400 lines) into multiple logical files, organizing code by responsibility. + +## Final File Structure + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39 lines) +├── pipeline_setup.go # SetupTurn method (~115 lines) +├── pipeline_llm.go # CallLLM method (~519 lines) +├── pipeline_execute.go # ExecuteTools method (~693 lines) +└── pipeline_finalize.go # Finalize method (~78 lines) +``` + +## Actual Line Counts + +| File | Lines | +|------|-------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **Total** | **1444** | + +## Responsibility Matrix + +| File | Method | Responsibility | +|------|--------|----------------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline dependency container | +| `pipeline_setup.go` | `SetupTurn()` | Turn initialization: history assembly, message building, candidate selection | +| `pipeline_llm.go` | `CallLLM()` | LLM call: PreLLM hooks, fallback, retry, AfterLLM hooks | +| `pipeline_execute.go` | `ExecuteTools()` | Tool execution: BeforeTool/ApproveTool/AfterTool hooks, media sending, steering handling | +| `pipeline_finalize.go` | `Finalize()` | Turn finalization: session save, compression, status setting | + +## Relationship Between Pipeline and Turn Coordinator + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── Publish response (agent_outbound.go) +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md new file mode 100644 index 000000000..2de1396ad --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md @@ -0,0 +1,68 @@ +# Pipeline 重构文档 + +## 目标 + +将 `agent/pipeline.go` (1400行) 拆分为多个逻辑文件,代码按职责组织。 + +## 最终文件结构 + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39行) +├── pipeline_setup.go # SetupTurn 方法 (~115行) +├── pipeline_llm.go # CallLLM 方法 (~519行) +├── pipeline_execute.go # ExecuteTools 方法 (~693行) +└── pipeline_finalize.go # Finalize 方法 (~78行) +``` + +## 实际行数 + +| 文件 | 行数 | +|------|------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **总计** | **1444** | + +## 职责说明 + +| 文件 | 方法 | 职责 | +|------|------|------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline 依赖容器 | +| `pipeline_setup.go` | `SetupTurn()` | Turn 初始化:历史组装、消息构建、候选人选择 | +| `pipeline_llm.go` | `CallLLM()` | LLM 调用:PreLLM hook、fallback、重试、AfterLLM hook | +| `pipeline_execute.go` | `ExecuteTools()` | 工具执行:BeforeTool/ApproveTool/AfterTool hook、媒体发送、steering 处理 | +| `pipeline_finalize.go` | `Finalize()` | Turn 终结:会话保存、压缩、状态设置 | + +## Pipeline 与 Turn Coordinator 的关系 + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── 发布响应 (agent_outbound.go) +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/hooks/README.md b/docs/architecture/hooks/README.md similarity index 100% rename from docs/hooks/README.md rename to docs/architecture/hooks/README.md diff --git a/docs/hooks/README.zh.md b/docs/architecture/hooks/README.zh.md similarity index 100% rename from docs/hooks/README.zh.md rename to docs/architecture/hooks/README.zh.md diff --git a/docs/hooks/hook-json-protocol.md b/docs/architecture/hooks/hook-json-protocol.md similarity index 100% rename from docs/hooks/hook-json-protocol.md rename to docs/architecture/hooks/hook-json-protocol.md diff --git a/docs/hooks/hook-json-protocol.zh.md b/docs/architecture/hooks/hook-json-protocol.zh.md similarity index 100% rename from docs/hooks/hook-json-protocol.zh.md rename to docs/architecture/hooks/hook-json-protocol.zh.md diff --git a/docs/hooks/plugin-tool-injection.md b/docs/architecture/hooks/plugin-tool-injection.md similarity index 100% rename from docs/hooks/plugin-tool-injection.md rename to docs/architecture/hooks/plugin-tool-injection.md diff --git a/docs/hooks/plugin-tool-injection.zh.md b/docs/architecture/hooks/plugin-tool-injection.zh.md similarity index 100% rename from docs/hooks/plugin-tool-injection.zh.md rename to docs/architecture/hooks/plugin-tool-injection.zh.md diff --git a/docs/architecture/routing-system.md b/docs/architecture/routing-system.md new file mode 100644 index 000000000..ad6c3abfc --- /dev/null +++ b/docs/architecture/routing-system.md @@ -0,0 +1,282 @@ +# Routing System + +> Back to [README](../README.md) + +In PicoClaw, the runtime "routing system" is not just one decision. +It is the combined pipeline that decides: + +1. which agent handles an inbound message +2. which session dimensions should isolate that conversation +3. whether the turn should use the agent's primary model or a configured light model + +This document covers the runtime path in `pkg/routing` and its integration in `pkg/agent`. +It does not describe the launcher's HTTP `ServeMux` routes or the frontend's TanStack Router files under `web/`. + +## Routing Layers + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Agent dispatch | `pkg/routing/route.go`, `pkg/routing/agent_id.go` | Choose the target agent for the inbound message. | +| Session policy selection | `pkg/routing/route.go` | Decide which dimensions should define session isolation for that routed turn. | +| Model routing | `pkg/routing/router.go`, `pkg/routing/features.go`, `pkg/routing/classifier.go` | Choose between the primary model and a configured light model based on message complexity. | +| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/agent_message.go`, `pkg/agent/turn_coord.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. | + +## End-To-End Flow + +The normal path for a user message is: + +```text +InboundMessage + -> NormalizeInboundContext + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> ensureSessionMetadata(...) + -> Router.SelectModel(...) + -> provider execution +``` + +The first half answers "who should handle this message and what session does it belong to". +The second half answers "which model tier should that agent use for this turn". + +## Agent Dispatch + +`routing.RouteResolver` turns a normalized `bus.InboundContext` into a `ResolvedRoute`: + +```go +type ResolvedRoute struct { + AgentID string + Channel string + AccountID string + SessionPolicy SessionPolicy + MatchedBy string +} +``` + +`MatchedBy` is a debugging aid. +Typical values are: + +- `default` +- `dispatch.rule` +- `dispatch.rule:` + +## Dispatch Input View + +Before matching rules, the resolver builds a normalized `dispatchView`. +Each field is normalized to the exact shape expected by rule matching. + +| Selector field | Runtime shape | +| --- | --- | +| `channel` | lowercased channel name | +| `account` | normalized account ID | +| `space` | `:` | +| `chat` | `:` | +| `topic` | `topic:` | +| `sender` | lowercased canonical sender ID | +| `mentioned` | boolean copied from inbound context | + +This means dispatch rules must match the normalized shape, for example: + +```json +{ + "agents": { + "dispatch": { + "rules": [ + { + "name": "support-group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123" + } + }, + { + "name": "slack-mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +## Dispatch Algorithm + +`ResolveRoute(...)` follows this sequence: + +1. Normalize `channel` and `account`. +2. Clone `session.identity_links` from config. +3. Build the normalized dispatch view. +4. Scan `agents.dispatch.rules` in order. +5. Skip rules with no constraints at all. +6. Return the first rule whose selector fields all match exactly. +7. If no rule matches, fall back to the default agent. + +Important consequences: + +- first match wins +- there is no score or priority field beyond list order +- invalid target agent IDs fall back to the default agent +- sender matching can see canonical identities produced by `identity_links` + +## Default Agent Resolution + +If no dispatch rule wins, or if a rule points at an unknown agent, the resolver picks a default agent using this order: + +1. the agent marked `default: true` +2. otherwise the first entry in `agents.list` +3. otherwise implicit `main` + +Both agent IDs and account IDs are normalized through the helpers in `pkg/routing/agent_id.go`. + +## Session Policy Handoff + +Agent dispatch does not directly build a session key. +Instead it emits a `SessionPolicy`: + +```go +type SessionPolicy struct { + Dimensions []string + IdentityLinks map[string][]string +} +``` + +The dimensions come from: + +- global `session.dimensions` +- or `dispatch_rule.session_dimensions` when the matching rule overrides them + +Only these dimension names survive normalization: + +- `space` +- `chat` +- `topic` +- `sender` + +Invalid or duplicated entries are silently dropped. + +`pkg/session/AllocateRouteSession(...)` then turns that policy into: + +- a structured `SessionScope` +- a canonical routed session key +- legacy compatibility aliases + +So the routing package owns "what should isolate this conversation", while the session package owns "how that isolation becomes keys and durable storage". + +## Identity Links + +`session.identity_links` is shared between dispatch and session allocation. +That is intentional: a sender canonicalized for routing should also map to the same session identity. + +Without that symmetry, the system could route two messages to the same agent but still fragment their history into different sessions. + +## Model Routing + +The second routing stage decides whether a turn can use a cheaper or faster light model. + +Config shape: + +```json +{ + "routing": { + "enabled": true, + "light_model": "gemini-2.0-flash", + "threshold": 0.35 + } +} +``` + +`pkg/routing.Router` compares the current turn against structural features and returns: + +- chosen model name +- whether the light model was used +- computed complexity score + +If the score is below the threshold, the light model wins. +Otherwise the agent's primary model is used. +At runtime this only matters when the agent actually has light-model candidates configured; otherwise execution stays on the primary candidate set. + +## Complexity Features + +`ExtractFeatures(...)` computes a language-agnostic feature vector: + +| Feature | Meaning | +| --- | --- | +| `TokenEstimate` | Approximate token count; CJK runes count more accurately than a flat rune split. | +| `CodeBlockCount` | Number of fenced code blocks in the current message. | +| `RecentToolCalls` | Tool-call count across the last six history entries. | +| `ConversationDepth` | Total history length. | +| `HasAttachments` | Detects embedded media or common media URL/file extensions. | + +This is intentionally structural rather than keyword-based, so the router behaves the same across languages. + +## RuleClassifier Scoring + +The current classifier is `RuleClassifier`. +It uses a weighted sum capped to `[0, 1]`. + +| Signal | Score | +| --- | --- | +| attachments present | `1.00` | +| token estimate `> 200` | `0.35` | +| token estimate `> 50` | `0.15` | +| code block present | `0.40` | +| recent tool calls `> 3` | `0.25` | +| recent tool calls `1..3` | `0.10` | +| conversation depth `> 10` | `0.10` | + +The default threshold is `0.35`. +That makes the following behavior intentional: + +- trivial chat stays on the light model +- code tasks usually jump to the heavy model immediately +- attachments always force the heavy model +- long, plain-text prompts cross the heavy-model boundary at the default threshold + +## Runtime Integration + +Agent dispatch and model routing happen in different places: + +- `pkg/agent/registry.go` owns `RouteResolver` +- `pkg/agent/agent_message.go` resolves the route and allocates session scope +- `pkg/agent/turn_coord.go:selectCandidates` calls `agent.Router.SelectModel(...)` + +When the light model is selected, the agent loop swaps to `agent.LightCandidates`. +When it is not selected, execution stays on the agent's primary provider candidate set. + +## Explicit Session Keys + +One nuance sits just outside `pkg/routing` but matters for the full routing story. + +After a route is allocated, `pkg/agent/agent_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied: + +- an opaque canonical key +- a legacy `agent:...` key + +That makes manual system flows, tests, and compatibility paths deterministic even when the normal routed scope would have produced a different key. + +## What This Document Does Not Cover + +The repository also contains two unrelated route systems: + +- backend HTTP routes registered in `web/backend/api/router.go` +- frontend file routes under `web/frontend/src/routes/` + +Those are launcher implementation details. +They are separate from the runtime routing system described here. + +## Related Files + +- `pkg/routing/route.go` +- `pkg/routing/router.go` +- `pkg/routing/classifier.go` +- `pkg/routing/features.go` +- `pkg/routing/agent_id.go` +- `pkg/session/allocator.go` +- `pkg/agent/registry.go` +- `pkg/agent/agent_message.go` +- `pkg/agent/turn_coord.go` diff --git a/docs/architecture/routing-system.zh.md b/docs/architecture/routing-system.zh.md new file mode 100644 index 000000000..018b9e7b2 --- /dev/null +++ b/docs/architecture/routing-system.zh.md @@ -0,0 +1,281 @@ +# 路由系统 + +> 返回 [README](../README.md) + +在 PicoClaw 里,“路由系统”不是单一判断。 +它实际上是组合起来的一条运行时决策链,负责决定: + +1. 哪个 agent 来处理一条入站消息 +2. 这条消息应该落在哪种 session 隔离维度下 +3. 这一轮该使用 agent 的主模型,还是配置中的轻量模型 + +本文覆盖 `pkg/routing` 及其在 `pkg/agent` 中的集成方式。 +它不讨论 `web/` 目录下 launcher 的 HTTP `ServeMux` 路由,也不讨论前端 TanStack Router 文件路由。 + +## 路由分层 + +| 层次 | 文件 | 作用 | +| --- | --- | --- | +| Agent 分发 | `pkg/routing/route.go`、`pkg/routing/agent_id.go` | 为入站消息选择目标 agent。 | +| Session 策略选择 | `pkg/routing/route.go` | 决定该 turn 的会话隔离维度。 | +| 模型路由 | `pkg/routing/router.go`、`pkg/routing/features.go`、`pkg/routing/classifier.go` | 根据消息复杂度在主模型和轻量模型之间做选择。 | +| 运行时集成 | `pkg/agent/registry.go`、`pkg/agent/loop_message.go`、`pkg/agent/loop_turn.go` | 应用 route 结果、分配 session scope,并在真正调用 provider 前选出模型候选集。 | + +## 端到端流程 + +普通用户消息的路径如下: + +```text +InboundMessage + -> NormalizeInboundContext + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> ensureSessionMetadata(...) + -> Router.SelectModel(...) + -> provider execution +``` + +前半段回答的是“谁来处理,以及属于哪段会话”。 +后半段回答的是“这个 agent 这一轮该走哪一档模型”。 + +## Agent 分发 + +`routing.RouteResolver` 会把归一化后的 `bus.InboundContext` 转成 `ResolvedRoute`: + +```go +type ResolvedRoute struct { + AgentID string + Channel string + AccountID string + SessionPolicy SessionPolicy + MatchedBy string +} +``` + +`MatchedBy` 主要用于日志和调试,常见值包括: + +- `default` +- `dispatch.rule` +- `dispatch.rule:` + +## Dispatch 输入视图 + +真正做规则匹配前,resolver 会先构造一个归一化后的 `dispatchView`。 +每个字段都会变成规则匹配所期待的固定形状。 + +| Selector 字段 | 运行时形状 | +| --- | --- | +| `channel` | 小写 channel 名称 | +| `account` | 归一化后的 account ID | +| `space` | `:` | +| `chat` | `:` | +| `topic` | `topic:` | +| `sender` | 小写 canonical sender ID | +| `mentioned` | 直接来自 inbound context 的布尔值 | + +这意味着 dispatch rule 必须写成归一化后的形状,例如: + +```json +{ + "agents": { + "dispatch": { + "rules": [ + { + "name": "support-group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-100123" + } + }, + { + "name": "slack-mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +## Dispatch 算法 + +`ResolveRoute(...)` 的流程是: + +1. 归一化 `channel` 和 `account`。 +2. 从配置复制 `session.identity_links`。 +3. 构建归一化后的 dispatch view。 +4. 按顺序扫描 `agents.dispatch.rules`。 +5. 没有任何约束条件的 rule 会被跳过。 +6. 第一个所有 selector 字段都精确匹配的 rule 胜出。 +7. 如果没有 rule 匹配,则回退到默认 agent。 + +这带来几个重要结论: + +- 第一条命中的规则优先,没有额外 priority 字段 +- rule 顺序本身就是优先级 +- 指向无效 agent 的 rule 最终会回退到默认 agent +- sender 匹配看到的是经过 `identity_links` 归一化后的身份 + +## 默认 Agent 解析 + +如果没有 dispatch rule 命中,或者 rule 指向了不存在的 agent,resolver 会按以下顺序选择默认 agent: + +1. `default: true` 的 agent +2. 否则取 `agents.list` 的第一项 +3. 如果配置里没有 agent,则使用隐式 `main` + +Agent ID 和 Account ID 都会经过 `pkg/routing/agent_id.go` 中的归一化逻辑。 + +## Session 策略交接 + +Agent 分发本身不会直接生成 session key。 +它只会产出一个 `SessionPolicy`: + +```go +type SessionPolicy struct { + Dimensions []string + IdentityLinks map[string][]string +} +``` + +维度来源有两种: + +- 全局 `session.dimensions` +- 如果命中的 dispatch rule 指定了 `session_dimensions`,则用 rule 覆盖 + +最终只有这些维度名会被保留下来: + +- `space` +- `chat` +- `topic` +- `sender` + +非法项或重复项会被静默丢弃。 + +随后 `pkg/session/AllocateRouteSession(...)` 再把这份策略转成: + +- 结构化 `SessionScope` +- canonical routed session key +- legacy 兼容 alias + +所以可以把职责边界理解为: + +- `pkg/routing` 决定“这段对话应该按什么维度隔离” +- `pkg/session` 决定“这些维度如何变成 key 和持久化状态” + +## Identity Links + +`session.identity_links` 会同时被 dispatch 和 session allocation 使用。 +这是刻意保持一致的设计:如果某个 sender 在路由阶段已经被规范化,那么 session 阶段也应该落到同一个身份上。 + +否则就会出现“消息路由到了同一个 agent,但上下文仍被拆成多个 session”的问题。 + +## 模型路由 + +第二阶段路由决定这一轮能否使用更便宜或更快的轻量模型。 + +配置形状如下: + +```json +{ + "routing": { + "enabled": true, + "light_model": "gemini-2.0-flash", + "threshold": 0.35 + } +} +``` + +`pkg/routing.Router` 会根据当前 turn 的结构特征,返回: + +- 选中的模型名 +- 是否使用了 light model +- 复杂度分数 + +当分数低于阈值时,走轻量模型;否则仍使用 agent 的主模型。 +但在运行时,只有当 agent 实际配置了 light-model candidates 时,这个判断才会产生效果;否则仍会停留在主模型候选集上。 + +## 复杂度特征 + +`ExtractFeatures(...)` 会计算一个与自然语言内容无关、偏结构化的特征向量: + +| 特征 | 含义 | +| --- | --- | +| `TokenEstimate` | 估算 token 数;对 CJK 文本比简单 rune 平分更准确。 | +| `CodeBlockCount` | 当前消息中 fenced code block 的数量。 | +| `RecentToolCalls` | 最近 6 条历史消息中的 tool call 总数。 | +| `ConversationDepth` | 整体历史长度。 | +| `HasAttachments` | 是否检测到嵌入媒体或常见媒体 URL / 文件扩展名。 | + +这样做的目的,是让模型路由不依赖关键词,从而在不同语言下都保持一致行为。 + +## RuleClassifier 评分 + +当前分类器是 `RuleClassifier`,使用加权求和并把结果截断到 `[0, 1]`。 + +| 信号 | 分值 | +| --- | --- | +| 存在附件 | `1.00` | +| token 估计 `> 200` | `0.35` | +| token 估计 `> 50` | `0.15` | +| 存在代码块 | `0.40` | +| 最近 tool calls `> 3` | `0.25` | +| 最近 tool calls `1..3` | `0.10` | +| 会话深度 `> 10` | `0.10` | + +默认阈值是 `0.35`。 +这意味着以下行为是刻意设计出来的: + +- 很轻的闲聊仍走轻量模型 +- 编码类请求通常会立刻切到重模型 +- 带附件的请求一定走重模型 +- 很长的纯文本请求在默认阈值下也会跨过重模型边界 + +## 运行时集成 + +Agent 分发和模型路由发生在不同位置: + +- `pkg/agent/registry.go` 持有 `RouteResolver` +- `pkg/agent/loop_message.go` 负责 resolve route 并分配 session scope +- `pkg/agent/loop_turn.go:selectCandidates` 调用 `agent.Router.SelectModel(...)` + +当 light model 被选中时,agent loop 会切换到 `agent.LightCandidates`。 +如果没有被选中,则继续使用 agent 的主 provider 候选集。 + +## 显式 Session Key + +还有一个不在 `pkg/routing` 内部、但对整体“路由语义”很重要的细节。 + +在 route 分配完成后,`pkg/agent/loop_utils.go:resolveScopeKey` 会优先保留调用方显式传入的 session key,只要它属于以下格式之一: + +- 不透明 canonical key +- legacy `agent:...` key + +这样一来,手工系统流、测试和兼容路径即使在正常路由 scope 会生成不同 key 的情况下,仍然能保持确定性。 + +## 本文不覆盖的内容 + +仓库里还存在两套和这里无关的“route”系统: + +- `web/backend/api/router.go` 注册的后端 HTTP 路由 +- `web/frontend/src/routes/` 下的前端文件路由 + +它们属于 launcher 的实现细节,和本文描述的运行时路由系统是两回事。 + +## 相关文件 + +- `pkg/routing/route.go` +- `pkg/routing/router.go` +- `pkg/routing/classifier.go` +- `pkg/routing/features.go` +- `pkg/routing/agent_id.go` +- `pkg/session/allocator.go` +- `pkg/agent/registry.go` +- `pkg/agent/loop_message.go` +- `pkg/agent/loop_turn.go` diff --git a/docs/architecture/session-system.md b/docs/architecture/session-system.md new file mode 100644 index 000000000..b87f9c38e --- /dev/null +++ b/docs/architecture/session-system.md @@ -0,0 +1,255 @@ +# Session System + +> Back to [README](../README.md) + +This document describes the runtime session system used by PicoClaw to: + +- map inbound messages onto stable conversation scopes +- persist message history and summaries +- preserve compatibility with legacy `agent:...` session keys while the runtime uses opaque canonical keys + +This document covers the core runtime path in `pkg/session`, `pkg/memory`, and `pkg/agent`. +It does not describe launcher login cookies or dashboard authentication sessions in `web/backend/middleware`. + +## Responsibilities + +The session system has four jobs: + +1. Decide which messages should share the same conversation context. +2. Persist that context durably across turns and restarts. +3. Expose a small `SessionStore` interface to the agent loop. +4. Keep older session-key formats working during storage and routing migrations. + +## Main Components + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Session contract | `pkg/session/session_store.go` | Defines the `SessionStore` interface used by the agent loop. | +| Legacy backend | `pkg/session/manager.go` | Stores one JSON file per session. Still used as a fallback. | +| Session adapter | `pkg/session/jsonl_backend.go` | Adapts `pkg/memory.Store` to `SessionStore`, including alias and scope metadata support. | +| Durable storage | `pkg/memory/jsonl.go` | Append-only JSONL storage plus `.meta.json` sidecar metadata. | +| Scope and key building | `pkg/session/scope.go`, `pkg/session/key.go`, `pkg/session/allocator.go` | Builds structured scopes, opaque canonical keys, and legacy aliases from routing results. | +| Runtime integration | `pkg/agent/instance.go`, `pkg/agent/agent.go`, `pkg/agent/agent_message.go` | Initializes the store, allocates session scope, and persists metadata before turns run. | + +## Session Data Model + +The structured session identity is represented by `session.SessionScope`: + +| Field | Meaning | +| --- | --- | +| `Version` | Schema version. Current value is `ScopeVersionV1`. | +| `AgentID` | Routed agent handling the turn. | +| `Channel` | Normalized inbound channel name. | +| `Account` | Normalized account or bot identifier. | +| `Dimensions` | Ordered list of active partition dimensions such as `chat` or `sender`. | +| `Values` | Concrete normalized values for each selected dimension. | + +Only four dimensions are currently recognized by the allocator: + +- `space` +- `chat` +- `topic` +- `sender` + +The default config uses: + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +That means one shared conversation per chat unless a dispatch rule overrides it. + +## Canonical Keys And Legacy Aliases + +The runtime now prefers opaque canonical keys: + +```text +sk_v1_ +``` + +These keys are built from a canonical scope signature in `pkg/session/key.go`. +The goal is to make storage keys stable while decoupling them from any specific legacy text format. + +For compatibility, the allocator also emits legacy aliases such as: + +```text +agent:main:direct:user123 +agent:main:slack:channel:c001 +agent:main:pico:direct:pico:session-123 +``` + +These aliases matter because older sessions, tests, and some tools still refer to the legacy shape. +The JSONL backend resolves aliases back to the canonical key before reads and writes. + +The agent loop also preserves explicit incoming session keys when the caller already supplied one of the recognized explicit formats: + +- opaque canonical key +- legacy `agent:...` key + +That behavior lives in `pkg/agent/agent_utils.go:resolveScopeKey`. + +## Allocation Flow + +The end-to-end flow for a normal inbound message is: + +```text +InboundMessage + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> resolveScopeKey(...) + -> ensureSessionMetadata(...) + -> AgentLoop turn execution + -> SessionStore read/write operations +``` + +More concretely: + +1. `pkg/agent/agent_message.go` resolves the agent route from normalized inbound context. +2. `session.AllocateRouteSession` converts the route's `SessionPolicy` plus inbound context into a structured `SessionScope`. +3. The allocator builds: + - `SessionKey`: canonical routed session key + - `SessionAliases`: compatibility aliases for that routed scope + - `MainSessionKey`: agent-level main session key + - `MainAliases`: legacy alias for the main session +4. `runAgentLoop` persists scope metadata and aliases through `ensureSessionMetadata`. +5. During later reads or writes, `JSONLBackend.ResolveSessionKey` maps aliases back onto the canonical key. + +The main session key is separate from routed chat sessions. +It is mainly used for agent-level or system-style flows that need one stable per-agent conversation, for example `processSystemMessage`. + +## Scope Construction Rules + +`pkg/session/allocator.go` builds scope values from normalized inbound context. +Important rules: + +- `space` becomes `:` +- `chat` becomes `:` +- `topic` becomes `topic:` +- `sender` is canonicalized through `session.identity_links` before being stored + +There are two special cases worth calling out. + +### Telegram forum isolation + +Telegram forum topics must stay isolated even when the configured dimensions only mention `chat`. +To preserve that behavior, the allocator appends `/` to the `chat` value for Telegram forum messages unless `topic` is already an explicit dimension. + +Example: + +```text +group:-1001234567890/42 +group:-1001234567890/99 +``` + +Those produce different session keys. + +### Identity links + +`session.identity_links` lets multiple sender identifiers collapse into one canonical identity. +Both dispatch matching and session allocation use that mapping so that the same person can keep one conversation even if their raw sender IDs differ across channels or accounts. + +## Storage Format + +The default runtime backend is `pkg/memory.JSONLStore`, wrapped by `session.JSONLBackend`. + +Each session uses two files: + +```text +{sanitized_key}.jsonl +{sanitized_key}.meta.json +``` + +The files store: + +- `.jsonl`: one `providers.Message` per line, append-only +- `.meta.json`: summary, timestamps, line counts, logical truncation offset, scope, aliases + +`SessionMeta` currently includes: + +- `Key` +- `Summary` +- `Skip` +- `Count` +- `CreatedAt` +- `UpdatedAt` +- `Scope` +- `Aliases` + +## Write And Crash Semantics + +The JSONL store is designed around append-first durability and stale-over-loss recovery: + +- `AddMessage` and `AddFullMessage` append one JSON line, `fsync`, then update metadata. +- `TruncateHistory` is logical first: it only advances `meta.Skip`. +- `Compact` physically rewrites the JSONL file to remove skipped lines. +- `SetHistory` and `Compact` write metadata before rewriting JSONL so a crash may temporarily expose old data, but should not lose data. +- Corrupt JSONL lines are skipped during reads instead of failing the entire session. + +`JSONLBackend.Save` maps onto `store.Compact(...)`. +In other words, `Save` is no longer "flush dirty memory to disk"; it is now "reclaim dead lines after logical truncation". + +## Concurrency Model + +`pkg/memory.JSONLStore` uses a fixed 64-shard mutex array keyed by session hash. +That gives per-session serialization without keeping an unbounded mutex map in memory. + +The legacy `SessionManager` uses a single in-memory map guarded by an RW mutex. + +Both backends satisfy the same `SessionStore` interface, which is why the agent loop does not need storage-specific code. + +## Compatibility And Migration + +`pkg/agent/instance.go:initSessionStore` prefers the JSONL backend. + +Startup sequence: + +1. Create `memory.NewJSONLStore(dir)`. +2. Run `memory.MigrateFromJSON(...)` to import legacy `.json` sessions. +3. Wrap the store with `session.NewJSONLBackend(store)`. +4. If JSONL initialization or migration fails, fall back to `session.NewSessionManager(dir)`. + +This fallback is intentional: a partial migration would be worse than staying on the legacy store for one run. + +### Alias promotion + +When canonical metadata is first created, `EnsureSessionMetadata` may promote history from a non-empty legacy alias into the canonical session. +That promotion only happens when the canonical session is still empty, so active canonical history is not overwritten. + +This is how the system preserves old histories such as: + +- legacy direct-message keys +- older Pico direct-session keys + +while moving the runtime onto opaque canonical keys. + +## Other SessionStore Implementations + +`pkg/agent/subturn.go` defines an `ephemeralSessionStore`. +It satisfies the same `SessionStore` interface, but keeps data in memory only and is destroyed when the sub-turn ends. + +That lets SubTurn reuse the same session-facing APIs without writing child-session history into the parent's durable storage. + +## Operational Consumers + +The session system is consumed by more than the agent loop: + +- `web/backend/api/session.go` reads JSONL metadata and legacy JSON sessions to expose session history in the launcher UI. +- `pkg/agent/steering.go` can recover scope metadata for active steering flows. +- tooling and tests can still refer to legacy aliases because alias resolution is handled below the agent loop. + +## Related Files + +- `pkg/session/session_store.go` +- `pkg/session/manager.go` +- `pkg/session/jsonl_backend.go` +- `pkg/session/scope.go` +- `pkg/session/key.go` +- `pkg/session/allocator.go` +- `pkg/memory/jsonl.go` +- `pkg/agent/instance.go` +- `pkg/agent/agent.go` +- `pkg/agent/agent_message.go` diff --git a/docs/architecture/session-system.zh.md b/docs/architecture/session-system.zh.md new file mode 100644 index 000000000..8de4e515c --- /dev/null +++ b/docs/architecture/session-system.zh.md @@ -0,0 +1,254 @@ +# Session 系统 + +> 返回 [README](../README.md) + +本文说明 PicoClaw 运行时的 Session 系统如何完成以下事情: + +- 把入站消息映射到稳定的会话作用域 +- 持久化消息历史与摘要 +- 在运行时使用不透明 canonical key 的同时,继续兼容旧的 `agent:...` session key + +本文覆盖 `pkg/session`、`pkg/memory` 和 `pkg/agent` 中的核心运行时链路。 +它不讨论 `web/backend/middleware` 中 launcher 登录 Cookie 或 dashboard 鉴权 session。 + +## 职责 + +Session 系统承担四件事: + +1. 决定哪些消息应该共享同一段上下文。 +2. 让这段上下文能跨 turn、跨进程重启持久存在。 +3. 向 agent loop 暴露一个足够小的 `SessionStore` 抽象。 +4. 在存储层和路由层迁移期间继续兼容旧 session key。 + +## 主要组件 + +| 层次 | 文件 | 作用 | +| --- | --- | --- | +| Session 抽象 | `pkg/session/session_store.go` | 定义 agent loop 依赖的 `SessionStore` 接口。 | +| 旧后端 | `pkg/session/manager.go` | 每个 session 一个 JSON 文件的旧实现,仍作为回退方案保留。 | +| Session 适配层 | `pkg/session/jsonl_backend.go` | 把 `pkg/memory.Store` 适配成 `SessionStore`,并支持 alias 与 scope metadata。 | +| 持久化存储 | `pkg/memory/jsonl.go` | Append-only JSONL 存储与 `.meta.json` 元数据侧文件。 | +| Scope / Key 构建 | `pkg/session/scope.go`、`pkg/session/key.go`、`pkg/session/allocator.go` | 从路由结果生成结构化 scope、不透明 canonical key 和 legacy alias。 | +| 运行时集成 | `pkg/agent/instance.go`、`pkg/agent/loop.go`、`pkg/agent/loop_message.go` | 初始化存储、分配 session scope,并在 turn 执行前落 metadata。 | + +## Session 数据模型 + +结构化的会话身份由 `session.SessionScope` 表示: + +| 字段 | 含义 | +| --- | --- | +| `Version` | Scope 模式版本,当前为 `ScopeVersionV1`。 | +| `AgentID` | 处理该 turn 的路由 agent。 | +| `Channel` | 归一化后的入站 channel 名称。 | +| `Account` | 归一化后的 bot / account 标识。 | +| `Dimensions` | 当前启用的隔离维度顺序,例如 `chat` 或 `sender`。 | +| `Values` | 每个维度对应的具体归一化值。 | + +Allocator 当前只识别四个维度: + +- `space` +- `chat` +- `topic` +- `sender` + +默认配置是: + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +也就是默认按 chat 共享上下文;如果 dispatch rule 覆盖了维度,则以 rule 为准。 + +## Canonical Key 与 Legacy Alias + +运行时现在优先使用不透明 canonical key: + +```text +sk_v1_ +``` + +它由 `pkg/session/key.go` 中的 scope signature 计算得到。 +这样可以让存储 key 稳定,同时不再把持久化格式和某一种旧文本 key 绑定死。 + +为了兼容旧数据,allocator 还会生成 legacy alias,例如: + +```text +agent:main:direct:user123 +agent:main:slack:channel:c001 +agent:main:pico:direct:pico:session-123 +``` + +这些 alias 很重要,因为旧 session、部分测试以及某些工具仍然会引用这种格式。 +JSONL backend 会在读写前先把 alias 解析回 canonical key。 + +此外,如果调用方已经显式传入了受支持的 session key,agent loop 会保留它,不强行改成新分配的 routed key。 +这条逻辑在 `pkg/agent/loop_utils.go:resolveScopeKey` 中: + +- 不透明 canonical key +- legacy `agent:...` key + +都属于“显式 key”。 + +## 分配流程 + +普通入站消息的完整链路如下: + +```text +InboundMessage + -> RouteResolver.ResolveRoute(...) + -> session.AllocateRouteSession(...) + -> resolveScopeKey(...) + -> ensureSessionMetadata(...) + -> AgentLoop turn 执行 + -> SessionStore 读写 +``` + +具体来说: + +1. `pkg/agent/loop_message.go` 先用归一化后的 inbound context 解析 agent route。 +2. `session.AllocateRouteSession` 把 route 的 `SessionPolicy` 和 inbound context 组合成结构化 `SessionScope`。 +3. Allocator 会生成: + - `SessionKey`:当前路由会话的 canonical key + - `SessionAliases`:该路由会话的兼容 alias + - `MainSessionKey`:agent 级主会话 key + - `MainAliases`:主会话对应的 legacy alias +4. `runAgentLoop` 通过 `ensureSessionMetadata` 持久化 scope metadata 和 alias。 +5. 后续读写时,`JSONLBackend.ResolveSessionKey` 会先把 alias 映射回 canonical key。 + +`MainSessionKey` 和普通聊天会话是分开的。 +它主要服务于 agent 级、系统级的上下文场景,比如 `processSystemMessage`。 + +## Scope 构建规则 + +`pkg/session/allocator.go` 会从归一化后的 inbound context 生成 scope 值。 +关键规则如下: + +- `space` 变成 `:` +- `chat` 变成 `:` +- `topic` 变成 `topic:` +- `sender` 会先经过 `session.identity_links` 归一化再写入 + +其中有两个需要单独记住的特殊规则。 + +### Telegram forum 隔离 + +Telegram forum topic 必须默认保持隔离,即使配置只写了 `chat` 维度。 +为此,如果消息来自 Telegram forum 且策略里没有显式包含 `topic`,allocator 会把 `/` 拼到 `chat` 值后面。 + +例如: + +```text +group:-1001234567890/42 +group:-1001234567890/99 +``` + +这两者会得到不同的 session key。 + +### Identity links + +`session.identity_links` 可以把多个 sender 标识折叠为一个 canonical identity。 +dispatch 匹配和 session 分配都会使用这套映射,因此同一个人即使跨 channel 或 account 使用不同原始 sender ID,也可以继续落到同一段上下文里。 + +## 存储格式 + +默认运行时后端是 `pkg/memory.JSONLStore`,外面包了一层 `session.JSONLBackend`。 + +每个 session 使用两类文件: + +```text +{sanitized_key}.jsonl +{sanitized_key}.meta.json +``` + +各自保存: + +- `.jsonl`:一行一个 `providers.Message`,append-only +- `.meta.json`:摘要、时间戳、行数、逻辑截断偏移、scope、aliases + +`SessionMeta` 当前包含: + +- `Key` +- `Summary` +- `Skip` +- `Count` +- `CreatedAt` +- `UpdatedAt` +- `Scope` +- `Aliases` + +## 写入与崩溃语义 + +JSONL store 的设计核心是“追加优先、宁可暂时读到旧数据也不要丢数据”: + +- `AddMessage` / `AddFullMessage` 先追加一行 JSON,再 `fsync`,最后更新 metadata。 +- `TruncateHistory` 先做逻辑截断,本质上只是推进 `meta.Skip`。 +- `Compact` 才会真正重写 JSONL 文件,把被跳过的旧行物理移除。 +- `SetHistory` 和 `Compact` 都会先写 metadata 再改写 JSONL;如果中途崩溃,最多短时间暴露旧数据,不应丢数据。 +- 读取 JSONL 时如果碰到损坏行,会跳过该行,而不是让整个 session 读取失败。 + +`JSONLBackend.Save` 对应到底层的 `store.Compact(...)`。 +也就是说,`Save` 在新实现里不再是“把内存脏数据刷盘”,而是“在逻辑截断后回收无效行占用的磁盘空间”。 + +## 并发模型 + +`pkg/memory.JSONLStore` 使用固定 64 分片 mutex,按 session key 的 hash 做串行化。 +这样既能做到“按 session 串行”,又不会因为 session 数量增长而把 mutex map 做成无界结构。 + +旧的 `SessionManager` 则是一个内存 map 加 RW mutex。 + +这两个实现都满足同一个 `SessionStore` 接口,所以 agent loop 不需要写任何存储后端特化逻辑。 + +## 兼容与迁移 + +`pkg/agent/instance.go:initSessionStore` 会优先初始化 JSONL 后端。 + +启动过程如下: + +1. 创建 `memory.NewJSONLStore(dir)`。 +2. 执行 `memory.MigrateFromJSON(...)`,把旧 `.json` session 迁入新格式。 +3. 用 `session.NewJSONLBackend(store)` 包装。 +4. 如果 JSONL 初始化或迁移失败,则回退到 `session.NewSessionManager(dir)`。 + +这个回退是刻意设计的:做一半的迁移,比整轮继续使用旧后端更危险。 + +### Alias 提升 + +第一次为 canonical key 建 metadata 时,`EnsureSessionMetadata` 会尝试把某个非空 legacy alias 的历史提升到 canonical session。 +但这件事只会在 canonical session 仍然为空时发生,因此不会覆盖已经存在的 canonical 历史。 + +这保证了系统在迁移到 opaque key 的同时,仍能保留旧历史,例如: + +- 旧的 direct-message key +- 旧的 Pico direct-session key + +## 其他 SessionStore 实现 + +`pkg/agent/subturn.go` 里定义了 `ephemeralSessionStore`。 +它同样实现 `SessionStore`,但只存在于内存里,在 sub-turn 结束时销毁。 + +这样 SubTurn 就能复用相同的 session 接口,而不会把子任务历史写进父会话的持久存储。 + +## 运行时消费者 + +Session 系统不只被 agent loop 使用: + +- `web/backend/api/session.go` 会读取 JSONL metadata 和旧 JSON session,并把历史暴露给 launcher UI。 +- `pkg/agent/steering.go` 可以在 steering 场景下恢复 scope metadata。 +- 因为 alias 解析发生在 agent loop 之下,测试和工具仍然可以继续使用 legacy alias。 + +## 相关文件 + +- `pkg/session/session_store.go` +- `pkg/session/manager.go` +- `pkg/session/jsonl_backend.go` +- `pkg/session/scope.go` +- `pkg/session/key.go` +- `pkg/session/allocator.go` +- `pkg/memory/jsonl.go` +- `pkg/agent/instance.go` +- `pkg/agent/loop.go` +- `pkg/agent/loop_message.go` diff --git a/docs/steering.md b/docs/architecture/steering.md similarity index 86% rename from docs/steering.md rename to docs/architecture/steering.md index 63294ac5f..1a993fdb3 100644 --- a/docs/steering.md +++ b/docs/architecture/steering.md @@ -170,13 +170,19 @@ This is saved to the session via `AddFullMessage` and sent to the model, so it i ## Automatic bus drain -When the agent loop (`Run()`) starts processing a message, it spawns a background goroutine that keeps consuming new inbound messages from the bus. These messages are automatically redirected into the steering queue via `Steer()`. This means: +When the agent loop (`Run()`) starts, it reads inbound messages from a shared message bus. The routing logic determines how each message is handled: -- Users on any channel (Telegram, Discord, etc.) don't need to do anything special — their messages are automatically captured as steering when the agent is busy -- Audio messages are transcribed before being steered, so the agent receives text. If transcription fails, the original (non-transcribed) message is steered as-is -- Only messages that resolve to the **same steering scope** as the active turn are redirected. Messages for other chats/sessions are requeued onto the inbound bus so they can be processed normally -- `system` inbound messages are not treated as steering input -- When `processMessage` finishes, the drain goroutine is canceled and normal message consumption resumes +1. **No active turn for the message's session** — the message is dispatched to a **worker goroutine** that processes the full turn (LLM calls, tool execution, steering drain) +2. **An active turn already exists for the same session** — the message is enqueued directly into that session's **steering queue** via `enqueueSteeringMessage`. No background drain goroutine is needed +3. **Non-routable message** (e.g. `system`) — processed synchronously in the main loop + +This design enables **parallel processing of messages from different sessions** while keeping same-session messages strictly sequential. Key implications: + +- Messages from different users/channels are processed **concurrently** (up to `max_parallel_turns`) +- Messages from the same session are **serialized** — subsequent messages go to the steering queue +- Users don't need to do anything special — their messages are automatically captured as steering when the agent is busy for their session +- Audio messages are transcribed within the worker that processes the turn, so the agent receives text +- `system` inbound messages are processed immediately and do not trigger steering ## Steering with media diff --git a/docs/subturn.md b/docs/architecture/subturn.md similarity index 85% rename from docs/subturn.md rename to docs/architecture/subturn.md index b84c06627..0a927b56d 100644 --- a/docs/subturn.md +++ b/docs/architecture/subturn.md @@ -112,13 +112,17 @@ When the parent task is forcefully aborted (e.g., user interrupts with `/stop`): ## Agent Loop Integration -### Bus Draining During Processing +### Message Routing and Steering -When a message enters the `Run()` loop, the agent starts a `drainBusToSteering` goroutine before calling `processMessage`. This goroutine runs concurrently with the entire processing lifecycle and continuously consumes any new inbound messages from the bus, redirecting them into the **steering queue** instead of dropping them. +When a message enters the `Run()` loop, the agent determines whether to start a new worker or enqueue to steering: -This ensures that if a user sends a follow-up message while the agent is processing (including during SubTurn execution), the message is not lost — it will be picked up between tool call iterations via `dequeueSteeringMessages`. +- If **no active turn** exists for the message's session key, the session is atomically reserved and a **worker goroutine** is spawned. The worker processes the full turn lifecycle: `processMessage` → tool execution → steering drain → `Continue` for queued messages. +- If an **active turn already exists** for the same session, the message is enqueued directly into that session's steering queue. It will be picked up by the existing worker's steering drain loop. -The drain goroutine stops automatically when `processMessage` returns (via a cancellable context). +This ensures that: +- Messages from **different sessions** are processed **in parallel** (up to `max_parallel_turns` concurrent workers) +- Messages from the **same session** are strictly **serialized** — they go to the steering queue and are processed sequentially within the active turn +- No background drain goroutine is needed; steering is handled by the worker itself after processing ### Pending Result Polling @@ -129,7 +133,7 @@ The agent loop polls for async SubTurn results at two points per iteration: ### Turn State Tracking -All active root turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns. +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 @@ -181,10 +185,10 @@ Creates a new spawner instance for the given AgentLoop. Pass the returned value ### Continue ```go -func (al *AgentLoop) Continue(ctx context.Context, sessionKey string) error +func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) ``` -Resumes an idle agent turn by injecting any queued steering messages as a new LLM iteration. Used when the agent is waiting and a deferred steering message needs to be processed without a new inbound message arriving. +Resumes an idle agent turn by dequeuing steering messages for the given session and running them through the agent loop. Returns the response string if processing occurred, or empty string if no steering messages were pending. Uses session-aware active turn checking — it only blocks if a turn is active for the *same* session, not for unrelated sessions. ## Context Propagation diff --git a/docs/channels/dingtalk/README.fr.md b/docs/channels/dingtalk/README.fr.md index eec59f6f2..ea0d45194 100644 --- a/docs/channels/dingtalk/README.fr.md +++ b/docs/channels/dingtalk/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # DingTalk diff --git a/docs/channels/dingtalk/README.ja.md b/docs/channels/dingtalk/README.ja.md index c465b6e2f..4796038f9 100644 --- a/docs/channels/dingtalk/README.ja.md +++ b/docs/channels/dingtalk/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # DingTalk diff --git a/docs/channels/dingtalk/README.pt-br.md b/docs/channels/dingtalk/README.pt-br.md index a96480342..c4a3da804 100644 --- a/docs/channels/dingtalk/README.pt-br.md +++ b/docs/channels/dingtalk/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # DingTalk diff --git a/docs/channels/dingtalk/README.vi.md b/docs/channels/dingtalk/README.vi.md index b760e28f7..83550a14e 100644 --- a/docs/channels/dingtalk/README.vi.md +++ b/docs/channels/dingtalk/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # DingTalk diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md index 13c7080b3..7c672c383 100644 --- a/docs/channels/dingtalk/README.zh.md +++ b/docs/channels/dingtalk/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 钉钉 diff --git a/docs/channels/discord/README.fr.md b/docs/channels/discord/README.fr.md index e8ac64668..951eb59be 100644 --- a/docs/channels/discord/README.fr.md +++ b/docs/channels/discord/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Discord diff --git a/docs/channels/discord/README.ja.md b/docs/channels/discord/README.ja.md index e4d71f41b..212abc1a3 100644 --- a/docs/channels/discord/README.ja.md +++ b/docs/channels/discord/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Discord diff --git a/docs/channels/discord/README.md b/docs/channels/discord/README.md index 771289d28..741bc64a1 100644 --- a/docs/channels/discord/README.md +++ b/docs/channels/discord/README.md @@ -8,26 +8,56 @@ Discord is a free voice, video, and text chat application designed for communiti ```json { + "agents": { + "defaults": { + "tool_feedback": { + "enabled": true, + "max_args_length": 300 + } + } + }, "channel_list": { "discord": { "enabled": true, "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], + "placeholder": { + "enabled": true, + "text": ["Thinking... 💭"] + }, "group_trigger": { "mention_only": false - } + }, + "reasoning_channel_id": "" } } } ``` -| Field | Type | Required | Description | -| ------------- | ------ | -------- | --------------------------------------------------------------------------- | -| enabled | bool | Yes | Whether to enable the Discord channel | -| token | string | Yes | Discord Bot Token | -| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | -| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| Field | Type | Required | Description | +| -------------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the Discord channel | +| token | string | Yes | Discord Bot Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| placeholder | object | No | Placeholder message config shown while the agent is working | +| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| reasoning_channel_id | string | No | Optional target channel ID for reasoning/thinking output | + +## Visible Execution Feedback + +Discord can show three different kinds of "working" feedback: + +1. Typing indicator: automatic, no extra config needed. +2. Placeholder message: enable `channel_list.discord.placeholder.enabled` to send a visible `Thinking...` message that is later edited into the final reply. +3. Tool execution feedback: enable `agents.defaults.tool_feedback.enabled` to send a short message before each tool call, for example: + +```text +🔧 `web_search` +Checking the latest PicoClaw release notes before I answer. +``` + +If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config. ## Setup diff --git a/docs/channels/discord/README.pt-br.md b/docs/channels/discord/README.pt-br.md index b782a944b..32d828b76 100644 --- a/docs/channels/discord/README.pt-br.md +++ b/docs/channels/discord/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Discord diff --git a/docs/channels/discord/README.vi.md b/docs/channels/discord/README.vi.md index ea25dc003..e9ad6f5cc 100644 --- a/docs/channels/discord/README.vi.md +++ b/docs/channels/discord/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Discord diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md index 30fe3d28b..d6785ac3b 100644 --- a/docs/channels/discord/README.zh.md +++ b/docs/channels/discord/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Discord diff --git a/docs/channels/feishu/README.fr.md b/docs/channels/feishu/README.fr.md index 8f9fdafcc..0d82c9655 100644 --- a/docs/channels/feishu/README.fr.md +++ b/docs/channels/feishu/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Feishu diff --git a/docs/channels/feishu/README.ja.md b/docs/channels/feishu/README.ja.md index 955ecc233..c19e9fbec 100644 --- a/docs/channels/feishu/README.ja.md +++ b/docs/channels/feishu/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # 飛書(Feishu) diff --git a/docs/channels/feishu/README.pt-br.md b/docs/channels/feishu/README.pt-br.md index 11089cf2c..73ab981e0 100644 --- a/docs/channels/feishu/README.pt-br.md +++ b/docs/channels/feishu/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Feishu diff --git a/docs/channels/feishu/README.vi.md b/docs/channels/feishu/README.vi.md index abe51db97..1db4c1146 100644 --- a/docs/channels/feishu/README.vi.md +++ b/docs/channels/feishu/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Feishu diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md index 882ee3d3f..afe117286 100644 --- a/docs/channels/feishu/README.zh.md +++ b/docs/channels/feishu/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 飞书 diff --git a/docs/channels/line/README.fr.md b/docs/channels/line/README.fr.md index 522ff1d2f..c37e1c3a0 100644 --- a/docs/channels/line/README.fr.md +++ b/docs/channels/line/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Line diff --git a/docs/channels/line/README.ja.md b/docs/channels/line/README.ja.md index a751d61e9..ed374c5e3 100644 --- a/docs/channels/line/README.ja.md +++ b/docs/channels/line/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Line diff --git a/docs/channels/line/README.pt-br.md b/docs/channels/line/README.pt-br.md index 73a1ab837..5feea3153 100644 --- a/docs/channels/line/README.pt-br.md +++ b/docs/channels/line/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Line diff --git a/docs/channels/line/README.vi.md b/docs/channels/line/README.vi.md index d799a934d..e834610e8 100644 --- a/docs/channels/line/README.vi.md +++ b/docs/channels/line/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Line diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md index cdc4380c3..5b353de1b 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Line diff --git a/docs/channels/maixcam/README.fr.md b/docs/channels/maixcam/README.fr.md index c4871f10a..23f8c11cc 100644 --- a/docs/channels/maixcam/README.fr.md +++ b/docs/channels/maixcam/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # MaixCam diff --git a/docs/channels/maixcam/README.ja.md b/docs/channels/maixcam/README.ja.md index 6d06370d7..adec19445 100644 --- a/docs/channels/maixcam/README.ja.md +++ b/docs/channels/maixcam/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # MaixCam diff --git a/docs/channels/maixcam/README.pt-br.md b/docs/channels/maixcam/README.pt-br.md index 6243bb67b..dd606ff53 100644 --- a/docs/channels/maixcam/README.pt-br.md +++ b/docs/channels/maixcam/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # MaixCam diff --git a/docs/channels/maixcam/README.vi.md b/docs/channels/maixcam/README.vi.md index 7f0dc5812..09aba3540 100644 --- a/docs/channels/maixcam/README.vi.md +++ b/docs/channels/maixcam/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # MaixCam diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md index f9e434976..2b4fdb87a 100644 --- a/docs/channels/maixcam/README.zh.md +++ b/docs/channels/maixcam/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # MaixCam diff --git a/docs/channels/matrix/README.fr.md b/docs/channels/matrix/README.fr.md index e4e1341c1..5ff329a28 100644 --- a/docs/channels/matrix/README.fr.md +++ b/docs/channels/matrix/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Guide de configuration du canal Matrix diff --git a/docs/channels/matrix/README.ja.md b/docs/channels/matrix/README.ja.md index fb80cd484..adb14a1f9 100644 --- a/docs/channels/matrix/README.ja.md +++ b/docs/channels/matrix/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Matrix チャンネル設定ガイド diff --git a/docs/channels/matrix/README.pt-br.md b/docs/channels/matrix/README.pt-br.md index 22deaf861..4f606f3ed 100644 --- a/docs/channels/matrix/README.pt-br.md +++ b/docs/channels/matrix/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Guia de Configuração do Canal Matrix diff --git a/docs/channels/matrix/README.vi.md b/docs/channels/matrix/README.vi.md index d01b5ae3d..27f2ce746 100644 --- a/docs/channels/matrix/README.vi.md +++ b/docs/channels/matrix/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Hướng dẫn Cấu hình Kênh Matrix diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md index 08a746d7f..97634e2e6 100644 --- a/docs/channels/matrix/README.zh.md +++ b/docs/channels/matrix/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Matrix 通道配置指南 diff --git a/docs/channels/onebot/README.fr.md b/docs/channels/onebot/README.fr.md index 209dd529d..8a2aec8d2 100644 --- a/docs/channels/onebot/README.fr.md +++ b/docs/channels/onebot/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # OneBot diff --git a/docs/channels/onebot/README.ja.md b/docs/channels/onebot/README.ja.md index d08908d69..d2616e582 100644 --- a/docs/channels/onebot/README.ja.md +++ b/docs/channels/onebot/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # OneBot diff --git a/docs/channels/onebot/README.pt-br.md b/docs/channels/onebot/README.pt-br.md index 7043cc867..2e037361f 100644 --- a/docs/channels/onebot/README.pt-br.md +++ b/docs/channels/onebot/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # OneBot diff --git a/docs/channels/onebot/README.vi.md b/docs/channels/onebot/README.vi.md index 5ee1f37fd..3dfcf8161 100644 --- a/docs/channels/onebot/README.vi.md +++ b/docs/channels/onebot/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # OneBot diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md index 6f9f07c0d..4e5210b82 100644 --- a/docs/channels/onebot/README.zh.md +++ b/docs/channels/onebot/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # OneBot diff --git a/docs/channels/qq/README.fr.md b/docs/channels/qq/README.fr.md index e46bd7ebd..2202fa09d 100644 --- a/docs/channels/qq/README.fr.md +++ b/docs/channels/qq/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # QQ diff --git a/docs/channels/qq/README.ja.md b/docs/channels/qq/README.ja.md index 791428cc2..d9e86a061 100644 --- a/docs/channels/qq/README.ja.md +++ b/docs/channels/qq/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # QQ diff --git a/docs/channels/qq/README.pt-br.md b/docs/channels/qq/README.pt-br.md index d5eb0080b..b0a7e5568 100644 --- a/docs/channels/qq/README.pt-br.md +++ b/docs/channels/qq/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # QQ diff --git a/docs/channels/qq/README.vi.md b/docs/channels/qq/README.vi.md index d3973df41..cf940d05d 100644 --- a/docs/channels/qq/README.vi.md +++ b/docs/channels/qq/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # QQ diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md index fa3b129e0..dc40f6225 100644 --- a/docs/channels/qq/README.zh.md +++ b/docs/channels/qq/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # QQ diff --git a/docs/channels/slack/README.fr.md b/docs/channels/slack/README.fr.md index 7d0d09f5d..be533052a 100644 --- a/docs/channels/slack/README.fr.md +++ b/docs/channels/slack/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Slack diff --git a/docs/channels/slack/README.ja.md b/docs/channels/slack/README.ja.md index b2184310e..38cfc0134 100644 --- a/docs/channels/slack/README.ja.md +++ b/docs/channels/slack/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Slack diff --git a/docs/channels/slack/README.pt-br.md b/docs/channels/slack/README.pt-br.md index 6d1b7c520..d2676d44a 100644 --- a/docs/channels/slack/README.pt-br.md +++ b/docs/channels/slack/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Slack diff --git a/docs/channels/slack/README.vi.md b/docs/channels/slack/README.vi.md index dff55b9ad..3bbbe3132 100644 --- a/docs/channels/slack/README.vi.md +++ b/docs/channels/slack/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Slack diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md index e8dba16b8..8ecfe88bf 100644 --- a/docs/channels/slack/README.zh.md +++ b/docs/channels/slack/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Slack diff --git a/docs/channels/telegram/README.fr.md b/docs/channels/telegram/README.fr.md index 944b0091f..51db2082f 100644 --- a/docs/channels/telegram/README.fr.md +++ b/docs/channels/telegram/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # Telegram diff --git a/docs/channels/telegram/README.ja.md b/docs/channels/telegram/README.ja.md index 58e4cbdfa..03303f255 100644 --- a/docs/channels/telegram/README.ja.md +++ b/docs/channels/telegram/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # Telegram diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index e4b298176..a4138009e 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -2,7 +2,7 @@ # Telegram -The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../providers.md#voice-transcription)), and built-in command handling. +The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../guides/providers.md#voice-transcription)), and built-in command handling. ## Configuration @@ -44,6 +44,8 @@ Telegram auto-registers PicoClaw's top-level bot commands at startup, including Skill-related commands: - `/list skills` lists the installed skills visible to the current agent. +- `/list mcp` lists configured MCP servers and whether they are deferred/connected. +- `/show mcp ` lists the active tools for a connected MCP server. - `/use ` forces a skill for a single request. - `/use ` arms the skill for your next message in the same chat. - `/use clear` clears a pending skill override. @@ -52,6 +54,8 @@ Examples: ```text /list skills +/list mcp +/show mcp github /use git explain how to squash the last 3 commits /use git explain how to squash the last 3 commits diff --git a/docs/channels/telegram/README.pt-br.md b/docs/channels/telegram/README.pt-br.md index 2cd4c99c7..4af8d7a25 100644 --- a/docs/channels/telegram/README.pt-br.md +++ b/docs/channels/telegram/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # Telegram diff --git a/docs/channels/telegram/README.vi.md b/docs/channels/telegram/README.vi.md index efe6cf821..c6a276754 100644 --- a/docs/channels/telegram/README.vi.md +++ b/docs/channels/telegram/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # Telegram diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index fa5dc42d6..543e16e47 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -1,8 +1,8 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # Telegram -Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../zh/providers.md#语音转录)),以及内置命令处理器。 +Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、语音转录(配置见[提供商与模型配置](../../guides/providers.zh.md#语音转录)),以及内置命令处理器。 ## 配置 diff --git a/docs/channels/vk/README.md b/docs/channels/vk/README.md index c3f4b80e4..5e0c72bce 100644 --- a/docs/channels/vk/README.md +++ b/docs/channels/vk/README.md @@ -101,7 +101,7 @@ The VK channel supports both voice message reception and text-to-speech capabili - **ASR (Automatic Speech Recognition)**: Voice messages can be transcribed to text using configured voice models - **TTS (Text-to-Speech)**: Text responses can be converted to voice messages -To enable voice transcription, configure a voice model in your providers setup. See [Voice Transcription](../../providers.md#voice-transcription) for details. +To enable voice transcription, configure a voice model in your providers setup. See [Voice Transcription](../../guides/providers.md#voice-transcription) for details. ### Group Chat Support diff --git a/docs/channels/wecom/README.fr.md b/docs/channels/wecom/README.fr.md index b2cad168e..843943bdf 100644 --- a/docs/channels/wecom/README.fr.md +++ b/docs/channels/wecom/README.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../../README.fr.md) +> Retour au [README](../../project/README.fr.md) # WeCom diff --git a/docs/channels/wecom/README.ja.md b/docs/channels/wecom/README.ja.md index 02224b6a9..459a922a6 100644 --- a/docs/channels/wecom/README.ja.md +++ b/docs/channels/wecom/README.ja.md @@ -1,4 +1,4 @@ -> [README](../../../README.ja.md) に戻る +> [README](../../project/README.ja.md) に戻る # WeCom diff --git a/docs/channels/wecom/README.pt-br.md b/docs/channels/wecom/README.pt-br.md index d20631910..07a5e23b9 100644 --- a/docs/channels/wecom/README.pt-br.md +++ b/docs/channels/wecom/README.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../../README.pt-br.md) +> Voltar ao [README](../../project/README.pt-br.md) # WeCom diff --git a/docs/channels/wecom/README.vi.md b/docs/channels/wecom/README.vi.md index 08d571e24..4769fd6d6 100644 --- a/docs/channels/wecom/README.vi.md +++ b/docs/channels/wecom/README.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../../README.vi.md) +> Quay lại [README](../../project/README.vi.md) # WeCom diff --git a/docs/channels/wecom/README.zh.md b/docs/channels/wecom/README.zh.md index 736ef969a..8303a8f8a 100644 --- a/docs/channels/wecom/README.zh.md +++ b/docs/channels/wecom/README.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../../README.zh.md) +> 返回 [README](../../project/README.zh.md) # 企业微信(WeCom) diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index 38f379c50..3c31f610f 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -154,7 +154,7 @@ Identify protocol via prefix in `model` field: | `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. | | `anthropic/` | Anthropic | Claude series specific | | `antigravity/` | Antigravity | Google Cloud Code Assist | -| `gemini/` | Gemini | Google Gemini native API (if needed) | +| `gemini/` | Gemini | Google Gemini native API | --- diff --git a/docs/design/steering-spec.md b/docs/design/steering-spec.md index 0951bf864..5fd8360b3 100644 --- a/docs/design/steering-spec.md +++ b/docs/design/steering-spec.md @@ -26,7 +26,8 @@ graph TD subgraph AgentLoop BUS[MessageBus] - DRAIN[drainBusToSteering goroutine] + ROUTE{Session Routing} + WP[Worker Pool] SQ[steeringQueue] RLI[runLLMIteration] TE[Tool Execution Loop] @@ -37,8 +38,11 @@ graph TD DC -->|PublishInbound| BUS SL -->|PublishInbound| BUS - BUS -->|ConsumeInbound while busy| DRAIN - DRAIN -->|Steer| SQ + BUS -->|ConsumeInbound| ROUTE + ROUTE -->|no active turn| WP + ROUTE -->|active turn exists| SQ + WP -->|Steer| SQ + WP -->|process| RLI RLI -->|1. initial poll| SQ TE -->|2. poll after each tool| SQ @@ -47,32 +51,34 @@ graph TD RLI -->|inject into context| LLM ``` -### Bus drain mechanism +### Message routing and worker pool -Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. Without additional wiring, these messages would sit in the bus buffer until the current `processMessage` finishes — meaning steering would never work for real users. +Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. The `Run()` loop consumes messages from the bus and routes each one based on its **session key**: -The solution: when `Run()` starts processing a message, it spawns a **drain goroutine** (`drainBusToSteering`) that keeps consuming from the bus and calling `Steer()`. When `processMessage` returns, the drain is canceled and normal consumption resumes. +- **No active turn for the session**: The session key is atomically reserved via `LoadOrStore(sessionKey, struct{}{})`, and a **worker goroutine** is spawned to process the full turn lifecycle. +- **Active turn exists for the session**: The message is enqueued directly into the steering queue via `enqueueSteeringMessage`. It will be picked up by the existing worker's steering drain loop. +- **Non-routable (system)**: Processed synchronously in the main loop. + +This enables **parallel processing of messages from different sessions** (up to `max_parallel_turns`) while keeping same-session messages strictly sequential. ```mermaid sequenceDiagram participant Bus participant Run - participant Drain - participant AgentLoop + participant Worker + participant SQ Run->>Bus: ConsumeInbound() → msg - Run->>Drain: spawn drainBusToSteering(ctx) - Run->>Run: processMessage(msg) + Run->>Run: resolveSteeringTarget(msg) → sessionKey - Note over Drain: running concurrently - - Bus-->>Drain: ConsumeInbound() → newMsg - Drain->>AgentLoop: al.transcribeAudioInMessage(ctx, newMsg) - Drain->>AgentLoop: Steer(providers.Message{Content: newMsg.Content}) - - Run->>Run: processMessage returns - Run->>Drain: cancel context - Note over Drain: exits + alt no active turn + Run->>Run: LoadOrStore(sessionKey, sentinel) + Run->>Worker: spawn worker goroutine + Worker->>Worker: processMessage(msg) + Worker->>SQ: drain steering after turn + else active turn exists + Run->>SQ: enqueueSteeringMessage(msg) + end ``` ## Data Structures @@ -121,7 +127,7 @@ A new field was added to `processOptions`: | `Steer` | `Steer(msg providers.Message) error` | Enqueues a steering message. Returns an error if the queue is full or not initialized. Thread-safe, can be called from any goroutine. | | `SteeringMode` | `SteeringMode() SteeringMode` | Returns the current dequeue mode. | | `SetSteeringMode` | `SetSteeringMode(mode SteeringMode)` | Changes the dequeue mode at runtime. | -| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages. Returns `""` if queue is empty. | +| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages for the given session. Returns `""` if queue is empty. Uses session-aware active turn checking (won't block on unrelated sessions). | ## Integration into the Agent Loop @@ -280,15 +286,17 @@ flowchart TD { "agents": { "defaults": { - "steering_mode": "one-at-a-time" + "steering_mode": "one-at-a-time", + "max_parallel_turns": 1 } } } ``` -| Field | Type | Default | Env var | -|-------|------|---------|---------| -| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` | +| Field | Type | Default | Env var | Description | +|-------|------|---------|---------|-------------| +| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` | How the steering queue is drained per poll | +| `max_parallel_turns` | `int` | `1` | `PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS` | Max concurrent turns. `0` or `1` = sequential; `>1` = parallel across sessions | ## Design decisions and trade-offs @@ -300,7 +308,8 @@ flowchart TD | `one-at-a-time` as default | Gives the model a chance to react to each steering message individually. More predictable behavior than dumping all messages at once. | | Skipped tools get explicit error results | The LLM protocol requires a tool result for every tool call in the assistant message. Omitting them would cause API errors. The skip message also informs the model about what was not done. | | `Continue()` uses `SkipInitialSteeringPoll` | Prevents race conditions and double-dequeuing when resuming an idle agent. | -| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the same steering queue since `processMessage` is sequential. | -| Bus drain goroutine in `Run()` | Channels (Telegram, Discord, etc.) publish to the bus via `PublishInbound`. Without the drain, messages would queue in the bus channel buffer and only be consumed after `processMessage` returns — defeating the purpose of steering. The drain goroutine bridges the gap by consuming new bus messages and calling `Steer()` while the agent is busy. | -| Audio transcription before steering | The drain goroutine calls `al.transcribeAudioInMessage(ctx, msg)` before steering, so voice messages are converted to text before the agent sees them. If transcription fails, the error is silently discarded and the original message is steered as-is. | +| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the steering queue since `processMessage` is sequential. | +| Worker pool dispatch in `Run()` | Messages are dispatched to a worker pool instead of a single sequential loop. The session key is atomically reserved via `LoadOrStore` before the worker starts, preventing TOCTOU races. Messages from the same session are serialized; different sessions are processed in parallel (up to `max_parallel_turns`). | +| No bus drain goroutine | The old `drainBusToSteering` goroutine has been removed. The main `Run()` loop now checks `activeTurnStates` for each inbound message: if a turn is active for the session, the message is enqueued directly to the steering queue; otherwise a new worker is spawned. This eliminates the complexity of drain cancellation and requeuing. | +| Audio transcription in worker | Audio is transcribed within the worker that processes the turn, not in a separate drain goroutine. | | `MaxQueueSize = 10` | Prevents unbounded memory growth if a user sends many messages while the agent is busy. Excess messages are dropped with a warning. | diff --git a/docs/fr/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.fr.md similarity index 98% rename from docs/fr/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.fr.md index d6d0a2bd4..5672952d3 100644 --- a/docs/fr/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Utiliser le fournisseur Antigravity dans PicoClaw diff --git a/docs/ja/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.ja.md similarity index 98% rename from docs/ja/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.ja.md index c044c1970..bd221ed1c 100644 --- a/docs/ja/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # PicoClaw で Antigravity プロバイダーを使用する diff --git a/docs/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.md similarity index 100% rename from docs/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.md diff --git a/docs/pt-br/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md similarity index 98% rename from docs/pt-br/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.pt-br.md index d4b681ad0..e5108916a 100644 --- a/docs/pt-br/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Usando o provedor Antigravity no PicoClaw diff --git a/docs/vi/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.vi.md similarity index 98% rename from docs/vi/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.vi.md index 4a696f770..54b4a6add 100644 --- a/docs/vi/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Sử dụng nhà cung cấp Antigravity trong PicoClaw diff --git a/docs/zh/ANTIGRAVITY_USAGE.md b/docs/guides/ANTIGRAVITY_USAGE.zh.md similarity index 98% rename from docs/zh/ANTIGRAVITY_USAGE.md rename to docs/guides/ANTIGRAVITY_USAGE.zh.md index 2218618a9..b4dde6ea3 100644 --- a/docs/zh/ANTIGRAVITY_USAGE.md +++ b/docs/guides/ANTIGRAVITY_USAGE.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 在 PicoClaw 中使用 Antigravity 提供商 diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 000000000..1a50a5062 --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,15 @@ +# Guides + +Task-oriented guides for setup, configuration, and common PicoClaw workflows. + +- [Docker & Quick Start Guide](docker.md): install and run PicoClaw with Docker or the launcher. +- [Configuration Guide](configuration.md): environment variables, workspace layout, routing, and sandbox settings. +- [Session Guide](session-guide.md): how session scope affects memory sharing, summaries, and isolation. +- [Routing Guide](routing-guide.md): agent dispatch, session overrides, and light-model routing. +- [Chat Apps Configuration](chat-apps.md): supported chat platforms and channel-specific setup paths. +- [Providers & Model Configuration](providers.md): `model_list`, providers, and model routing. +- [Spawn & Async Tasks](spawn-tasks.md): background work, long-running tasks, and sub-agent orchestration. +- [PicoClaw Hardware Compatibility List](hardware-compatibility.md): tested boards and platform notes. +- [Using Antigravity Provider in PicoClaw](ANTIGRAVITY_USAGE.md): Google Cloud Code Assist setup and usage. + +Translations usually live beside the English source when available. diff --git a/docs/fr/chat-apps.md b/docs/guides/chat-apps.fr.md similarity index 96% rename from docs/fr/chat-apps.md rename to docs/guides/chat-apps.fr.md index d6590f9ba..d9112c595 100644 --- a/docs/fr/chat-apps.md +++ b/docs/guides/chat-apps.fr.md @@ -1,6 +1,6 @@ # 💬 Configuration des Applications de Chat -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## 💬 Applications de Chat @@ -19,7 +19,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din | **QQ** | ⭐⭐ Moyen | API bot officielle, communauté chinoise | [Documentation](../channels/qq/README.fr.md) | | **DingTalk** | ⭐⭐ Moyen | Mode Stream (pas d'IP publique requise), entreprise | [Documentation](../channels/dingtalk/README.fr.md) | | **LINE** | ⭐⭐⭐ Avancé | HTTPS Webhook requis | [Documentation](../channels/line/README.fr.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.fr.md) / [App](../channels/wecom/wecom_app/README.fr.md) / [AI Bot](../channels/wecom/wecom_aibot/README.fr.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Avancé | Bot groupe (Webhook), app personnalisée (API), AI Bot | [Guide](../channels/wecom/README.fr.md) | | **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) | | **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) | | **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) | @@ -61,11 +61,19 @@ picoclaw gateway **4. Menu de commandes Telegram (enregistré automatiquement au démarrage)** -PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés. +PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés. L'enregistrement du menu de commandes Telegram reste une découverte UX locale au canal ; l'exécution générique des commandes est gérée de manière centralisée dans la boucle agent via l'exécuteur de commandes. Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le canal démarre quand même et PicoClaw réessaie l'enregistrement en arrière-plan. +Vous pouvez aussi gerer les competences installees directement depuis Telegram : + +- `/list skills` +- `/use ` +- `/use ` puis envoyer la vraie requete dans le message suivant +- `/use clear` +- `/btw ` pour poser une question annexe immediate sans modifier l'historique actif de la session ; `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils + @@ -383,7 +391,7 @@ PicoClaw prend en charge trois types d'intégration WeCom : **Option 2 : WeCom App (Application personnalisée)** - Plus de fonctionnalités, messagerie proactive, chat privé uniquement **Option 3 : WeCom AI Bot (Bot IA)** - Bot IA officiel, réponses en streaming, prend en charge les discussions de groupe et privées -Voir le [Guide de Configuration WeCom AI Bot](../channels/wecom/wecom_aibot/README.fr.md) pour les instructions détaillées. +Voir le [Guide de Configuration WeCom](../channels/wecom/README.fr.md) pour les instructions détaillées. **Configuration rapide - WeCom Bot :** diff --git a/docs/ja/chat-apps.md b/docs/guides/chat-apps.ja.md similarity index 97% rename from docs/ja/chat-apps.md rename to docs/guides/chat-apps.ja.md index 997748939..49c41a66e 100644 --- a/docs/ja/chat-apps.md +++ b/docs/guides/chat-apps.ja.md @@ -1,6 +1,6 @@ # 💬 チャットアプリ設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## 💬 チャットアプリ連携 @@ -21,7 +21,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして | **QQ** | ⭐⭐ 中程度 | 公式ボット API、中国コミュニティ向け | [ドキュメント](../channels/qq/README.ja.md) | | **DingTalk** | ⭐⭐ 中程度 | Stream モード(公開 IP 不要)、企業向け | [ドキュメント](../channels/dingtalk/README.ja.md) | | **LINE** | ⭐⭐⭐ やや難 | HTTPS Webhook が必要 | [ドキュメント](../channels/line/README.ja.md) | -| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [Bot](../channels/wecom/wecom_bot/README.ja.md) / [App](../channels/wecom/wecom_app/README.ja.md) / [AI Bot](../channels/wecom/wecom_aibot/README.ja.md) | +| **WeCom (企業微信)** | ⭐⭐⭐ やや難 | グループ Bot (Webhook)、カスタムアプリ (API)、AI Bot 対応 | [ガイド](../channels/wecom/README.ja.md) | | **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) | | **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) | | **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) | @@ -65,7 +65,7 @@ picoclaw gateway **4. Telegram コマンドメニュー(起動時に自動登録)** -PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。 +PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。 Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。 ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。 @@ -502,7 +502,7 @@ PicoClaw は 3 種類の WeCom 統合をサポートしています: **方式 2: カスタムアプリ (App)** — より多機能、プロアクティブメッセージング、プライベートチャットのみ **方式 3: AI Bot** — 公式 AI Bot、ストリーミング返信、グループ・プライベートチャット対応 -詳細なセットアップ手順は [WeCom AI Bot 設定ガイド](../channels/wecom/wecom_aibot/README.ja.md) を参照してください。 +詳細なセットアップ手順は [WeCom 設定ガイド](../channels/wecom/README.ja.md) を参照してください。 **クイックセットアップ — グループ Bot:** diff --git a/docs/chat-apps.md b/docs/guides/chat-apps.md similarity index 87% rename from docs/chat-apps.md rename to docs/guides/chat-apps.md index ae98a7d9f..62418f91a 100644 --- a/docs/chat-apps.md +++ b/docs/guides/chat-apps.md @@ -10,20 +10,20 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, | Channel | Difficulty | Description | Documentation | | -------------------- | ------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](channels/telegram/README.md) | -| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](channels/discord/README.md) | +| **Telegram** | ⭐ Easy | Recommended, voice-to-text, long polling (no public IP needed) | [Docs](../channels/telegram/README.md) | +| **Discord** | ⭐ Easy | Socket Mode, group/DM support, rich bot ecosystem | [Docs](../channels/discord/README.md) | | **WhatsApp** | ⭐ Easy | Native (QR scan) or Bridge URL | [Docs](#whatsapp) | | **Weixin** | ⭐ Easy | Native QR scan (Tencent iLink API) | [Docs](#weixin) | -| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](channels/slack/README.md) | -| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](channels/matrix/README.md) | -| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](channels/qq/README.md) | -| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](channels/dingtalk/README.md) | -| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](channels/line/README.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](channels/wecom/README.md) | -| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](channels/feishu/README.md) | +| **Slack** | ⭐ Easy | **Socket Mode** (no public IP needed), enterprise | [Docs](../channels/slack/README.md) | +| **Matrix** | ⭐⭐ Medium | Federated protocol, self-hosting supported | [Docs](../channels/matrix/README.md) | +| **QQ** | ⭐⭐ Medium | Official bot API, Chinese community | [Docs](../channels/qq/README.md) | +| **DingTalk** | ⭐⭐ Medium | Stream mode (no public IP needed), enterprise | [Docs](../channels/dingtalk/README.md) | +| **LINE** | ⭐⭐⭐ Advanced | HTTPS Webhook required | [Docs](../channels/line/README.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Advanced | Official AI Bot over WebSocket, streaming + media | [Docs](../channels/wecom/README.md) | +| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) | | **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) | -| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](channels/onebot/README.md) | -| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](channels/maixcam/README.md) | +| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) | +| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) | | **Pico** | ⭐ Easy | Native PicoClaw protocol channel | | @@ -62,17 +62,20 @@ picoclaw gateway **4. Telegram command menu (auto-registered at startup)** -PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) so command menu and runtime behavior stay in sync. +PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) so command menu and runtime behavior stay in sync. Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor. If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. -You can also manage installed skills directly from Telegram: +You can also inspect skills and MCP servers directly from Telegram: - `/list skills` +- `/list mcp` +- `/show mcp ` - `/use ` - `/use ` and then send the actual request in the next message - `/use clear` +- `/btw ` to ask an immediate side question without changing the active session history; `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow **4. Advanced Formatting** You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks. @@ -330,7 +333,7 @@ picoclaw gateway picoclaw gateway ``` -For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](channels/matrix/README.md). +For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](../channels/matrix/README.md). @@ -391,7 +394,7 @@ picoclaw gateway PicoClaw now exposes WeCom as a single AI Bot channel over WebSocket. No public webhook callback URL is required. -See [WeCom Configuration Guide](channels/wecom/README.md) for the full configuration reference and migration notes. +See [WeCom Configuration Guide](../channels/wecom/README.md) for the full configuration reference and migration notes. **Quick Setup - Recommended** @@ -471,7 +474,7 @@ picoclaw gateway Open Feishu, search for your bot name, and start chatting. You can also add the bot to a group — use `group_trigger.mention_only: true` to only respond when @mentioned. -For full options, see [Feishu Channel Configuration Guide](channels/feishu/README.md). +For full options, see [Feishu Channel Configuration Guide](../channels/feishu/README.md). diff --git a/docs/my/chat-apps.md b/docs/guides/chat-apps.ms.md similarity index 94% rename from docs/my/chat-apps.md rename to docs/guides/chat-apps.ms.md index c42436139..6bfa7565e 100644 --- a/docs/my/chat-apps.md +++ b/docs/guides/chat-apps.ms.md @@ -1,6 +1,6 @@ # 💬 Konfigurasi Aplikasi Sembang -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## 💬 Aplikasi Sembang @@ -60,11 +60,19 @@ picoclaw gateway **4. Menu arahan Telegram (auto-register semasa startup)** -PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`) supaya menu arahan dan tingkah laku runtime sentiasa selari. +PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) supaya menu arahan dan tingkah laku runtime sentiasa selari. Pendaftaran menu arahan Telegram kekal sebagai UX penemuan setempat saluran; pelaksanaan arahan generik dikendalikan secara berpusat dalam gelung agen melalui commands executor. Jika pendaftaran arahan gagal (ralat sementara rangkaian/API), saluran tetap akan bermula dan PicoClaw akan mencuba semula pendaftaran di latar belakang. +Anda juga boleh mengurus skill yang dipasang terus dari Telegram: + +- `/list skills` +- `/use ` +- `/use ` kemudian hantar permintaan sebenar dalam mesej seterusnya +- `/use clear` +- `/btw ` untuk bertanya soalan sampingan segera tanpa mengubah sejarah sesi aktif; `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa + **4. Pemformatan Lanjutan** Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemformatan yang lebih maju. Ini membolehkan bot menggunakan keseluruhan set ciri Telegram MarkdownV2, termasuk gaya bersarang, spoiler, dan blok lebar tetap tersuai. @@ -271,7 +279,7 @@ picoclaw gateway picoclaw gateway ``` -Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](docs/channels/matrix/README.md). +Untuk pilihan penuh (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), lihat [Panduan Konfigurasi Saluran Matrix](../channels/matrix/README.md). @@ -333,7 +341,7 @@ PicoClaw menyokong tiga jenis integrasi WeCom: **Pilihan 2: WeCom App (Custom App)** - Lebih banyak ciri, pemesejan proaktif, sembang peribadi sahaja **Pilihan 3: WeCom AI Bot (AI Bot)** - AI Bot rasmi, balasan streaming, menyokong sembang kumpulan & peribadi -Lihat [Panduan Konfigurasi WeCom AI Bot](docs/channels/wecom/wecom_aibot/README.zh.md) untuk arahan penyediaan terperinci. +Lihat [Panduan Konfigurasi WeCom](../channels/wecom/README.zh.md) untuk arahan penyediaan terperinci. **Quick Setup - WeCom Bot:** diff --git a/docs/pt-br/chat-apps.md b/docs/guides/chat-apps.pt-br.md similarity index 96% rename from docs/pt-br/chat-apps.md rename to docs/guides/chat-apps.pt-br.md index 732cdb1dc..6d4fbdc23 100644 --- a/docs/pt-br/chat-apps.md +++ b/docs/guides/chat-apps.pt-br.md @@ -1,6 +1,6 @@ # 💬 Configuração de Aplicativos de Chat -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## 💬 Aplicativos de Chat @@ -19,7 +19,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D | **QQ** | ⭐⭐ Médio | API bot oficial, comunidade chinesa | [Documentação](../channels/qq/README.pt-br.md) | | **DingTalk** | ⭐⭐ Médio | Modo Stream (sem IP público), empresarial | [Documentação](../channels/dingtalk/README.pt-br.md) | | **LINE** | ⭐⭐⭐ Avançado | HTTPS Webhook obrigatório | [Documentação](../channels/line/README.pt-br.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.pt-br.md) / [App](../channels/wecom/wecom_app/README.pt-br.md) / [AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Avançado | Bot de grupo (Webhook), app personalizado (API), AI Bot | [Guia](../channels/wecom/README.pt-br.md) | | **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) | | **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) | | **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) | @@ -61,11 +61,19 @@ picoclaw gateway **4. Menu de comandos do Telegram (registrado automaticamente na inicialização)** -O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados. +O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados. O registro do menu de comandos do Telegram permanece como descoberta UX local do canal; a execução genérica de comandos é tratada centralmente no loop do agente via commands executor. Se o registro de comandos falhar (erros transitórios de rede/API), o canal ainda inicia e o PicoClaw tenta novamente o registro em segundo plano. +Voce tambem pode gerenciar skills instaladas diretamente pelo Telegram: + +- `/list skills` +- `/use ` +- `/use ` e depois enviar a solicitacao real na proxima mensagem +- `/use clear` +- `/btw ` para fazer uma pergunta lateral imediata sem alterar o historico ativo da sessao; `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas + @@ -408,7 +416,7 @@ O PicoClaw suporta três tipos de integração WeCom: **Opção 2: WeCom App (App Personalizado)** - Mais recursos, mensagens proativas, apenas chat privado **Opção 3: WeCom AI Bot (AI Bot)** - AI Bot oficial, respostas em streaming, suporta chat de grupo e privado -Veja o [Guia de Configuração do WeCom AI Bot](../channels/wecom/wecom_aibot/README.pt-br.md) para instruções detalhadas de configuração. +Veja o [Guia de Configuração do WeCom](../channels/wecom/README.pt-br.md) para instruções detalhadas de configuração. **Configuração Rápida - WeCom Bot:** diff --git a/docs/vi/chat-apps.md b/docs/guides/chat-apps.vi.md similarity index 96% rename from docs/vi/chat-apps.md rename to docs/guides/chat-apps.vi.md index 5eb7c9488..8d0b4ee32 100644 --- a/docs/vi/chat-apps.md +++ b/docs/guides/chat-apps.vi.md @@ -1,6 +1,6 @@ # 💬 Cấu Hình Ứng Dụng Chat -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## 💬 Ứng Dụng Chat @@ -19,7 +19,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix | **QQ** | ⭐⭐ Trung bình | API bot chính thức, cộng đồng Trung Quốc | [Tài liệu](../channels/qq/README.vi.md) | | **DingTalk** | ⭐⭐ Trung bình | Chế độ Stream (không cần IP công khai), doanh nghiệp | [Tài liệu](../channels/dingtalk/README.vi.md) | | **LINE** | ⭐⭐⭐ Nâng cao | Yêu cầu HTTPS Webhook | [Tài liệu](../channels/line/README.vi.md) | -| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Bot](../channels/wecom/wecom_bot/README.vi.md) / [App](../channels/wecom/wecom_app/README.vi.md) / [AI Bot](../channels/wecom/wecom_aibot/README.vi.md) | +| **WeCom (企业微信)** | ⭐⭐⭐ Nâng cao | Bot nhóm (Webhook), ứng dụng tùy chỉnh (API), AI Bot | [Hướng dẫn](../channels/wecom/README.vi.md) | | **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) | | **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) | | **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) | @@ -61,11 +61,19 @@ picoclaw gateway **4. Menu lệnh Telegram (tự động đăng ký khi khởi động)** -PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`) để menu lệnh và hành vi runtime luôn đồng bộ. +PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) để menu lệnh và hành vi runtime luôn đồng bộ. Đăng ký menu lệnh Telegram vẫn là UX khám phá cục bộ của kênh; thực thi lệnh chung được xử lý tập trung trong vòng lặp agent qua commands executor. Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫn khởi động và PicoClaw thử lại đăng ký trong nền. +Ban cung co the quan ly skill da cai dat truc tiep tu Telegram: + +- `/list skills` +- `/use ` +- `/use ` roi gui yeu cau that o tin nhan tiep theo +- `/use clear` +- `/btw ` de hoi them mot cau ngoai le ngay lap tuc ma khong thay doi lich su phien dang hoat dong; `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong + @@ -408,7 +416,7 @@ PicoClaw hỗ trợ ba loại tích hợp WeCom: **Tùy chọn 2: WeCom App (App Tùy chỉnh)** - Nhiều tính năng hơn, nhắn tin chủ động, chỉ chat riêng **Tùy chọn 3: WeCom AI Bot (AI Bot)** - AI Bot chính thức, phản hồi streaming, hỗ trợ chat nhóm & riêng -Xem [Hướng Dẫn Cấu Hình WeCom AI Bot](../channels/wecom/wecom_aibot/README.vi.md) để biết hướng dẫn thiết lập chi tiết. +Xem [Hướng Dẫn Cấu Hình WeCom](../channels/wecom/README.vi.md) để biết hướng dẫn thiết lập chi tiết. **Thiết Lập Nhanh - WeCom Bot:** diff --git a/docs/zh/chat-apps.md b/docs/guides/chat-apps.zh.md similarity index 98% rename from docs/zh/chat-apps.md rename to docs/guides/chat-apps.zh.md index 4a59d528f..b5891dc69 100644 --- a/docs/zh/chat-apps.md +++ b/docs/guides/chat-apps.zh.md @@ -1,6 +1,6 @@ # 💬 聊天应用配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## 💬 聊天应用集成 (Chat Apps) @@ -65,7 +65,7 @@ picoclaw gateway **4. Telegram 命令菜单(启动时自动注册)** -PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 +PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`、`/use`、`/btw`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。 Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。 如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。 @@ -76,6 +76,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行 - `/use ` - `/use `,然后在下一条消息里发送真正的请求 - `/use clear` +- `/btw `,用于发起一个不改动当前会话历史的即时旁支提问;`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程 diff --git a/docs/fr/configuration.md b/docs/guides/configuration.fr.md similarity index 90% rename from docs/fr/configuration.md rename to docs/guides/configuration.fr.md index 7a57cceae..786a0c28f 100644 --- a/docs/fr/configuration.md +++ b/docs/guides/configuration.fr.md @@ -1,6 +1,6 @@ # ⚙️ Guide de Configuration -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## ⚙️ Configuration @@ -80,10 +80,30 @@ Pour les configurations avancées/de test, vous pouvez remplacer la racine des c export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Utiliser les Commandes Depuis les Canaux de Chat + +Une fois les compétences installées, vous pouvez aussi les inspecter et les activer directement depuis un canal de chat : + +- `/list skills` affiche les noms des compétences installées visibles pour l'agent courant. +- `/use ` force une compétence pour une seule requête. +- `/use ` prépare cette compétence pour votre prochain message dans la meme conversation. +- `/use clear` annule une surcharge de compétence en attente creee via `/use `. +- `/btw ` pose une question annexe immediate sans modifier l'historique courant de la session. `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils. + +Exemples : + +```text +/list skills +/use git explique comment squash les 3 derniers commits +/btw rappelle-moi ce qu'on a deja decide pour le plan de deploiement +/use italiapersonalfinance +dammi le ultime news +``` + ### Politique Unifiée d'Exécution des Commandes - Les commandes slash génériques sont exécutées via un chemin unique dans `pkg/agent/loop.go` via `commands.Executor`. -- Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement les commandes prises en charge au démarrage. +- Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement au démarrage les commandes prises en charge, comme `/start`, `/help`, `/show`, `/list`, `/use` et `/btw`. - Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal. - Une commande enregistrée mais non prise en charge sur le canal actuel (par exemple `/show` sur WhatsApp) renvoie une erreur explicite à l'utilisateur et arrête le traitement ultérieur. @@ -319,7 +339,7 @@ Répond HEARTBEAT_OK Utilisateur reçoit le résultat | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obtenir](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) | @@ -349,9 +369,12 @@ L'ancienne configuration `providers` est **dépréciée** et a été supprimée PicoClaw route les providers par famille de protocole : - **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres. +- **Gemini natif** : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - **Anthropic** : Comportement natif de l'API Claude. - **Codex/OAuth** : Route d'authentification OAuth/token OpenAI. +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`). + ### Tâches Planifiées / Rappels PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes. @@ -373,7 +396,7 @@ Les tâches planifiées persistent après redémarrage dans `~/.picoclaw/workspa | Sujet | Description | | ----- | ----------- | -| [Système de Hooks](../hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation | -| [Steering](../steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | -| [SubTurn](../subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | -| [Gestion du Contexte](../agent-refactor/context.md) | Détection des limites de contexte, compression | +| [Système de Hooks](../architecture/hooks/README.md) | Hooks événementiels : observateurs, intercepteurs, hooks d'approbation | +| [Steering](../architecture/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | +| [SubTurn](../architecture/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | +| [Gestion du Contexte](../architecture/agent-refactor/context.md) | Détection des limites de contexte, compression | diff --git a/docs/ja/configuration.md b/docs/guides/configuration.ja.md similarity index 90% rename from docs/ja/configuration.md rename to docs/guides/configuration.ja.md index 6d6290e8a..0234edbd7 100644 --- a/docs/ja/configuration.md +++ b/docs/guides/configuration.ja.md @@ -1,6 +1,6 @@ # ⚙️ 設定ガイド -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## ⚙️ 設定詳細 @@ -81,10 +81,30 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### チャットチャネルからスキルとコマンドを使う + +スキルをインストールすると、チャットチャネルから直接確認したり明示的に適用したりできます: + +- `/list skills` は現在の Agent から見えるインストール済みスキル名を表示します。 +- `/use ` は 1 回のリクエストだけそのスキルを強制します。 +- `/use ` は同じチャット内の次のメッセージにそのスキルを予約します。 +- `/use clear` は `/use ` で設定した保留中のスキル上書きを解除します。 +- `/btw ` は現在のセッション履歴を変更せずに即時の横道の質問を送ります。`/btw` はツールなしの直接質問として処理され、通常のツール実行フローには入りません。 + +例: + +```text +/list skills +/use git 直近 3 つのコミットを squash する方法を教えて +/btw さっきのデプロイ方針の結論だけもう一度教えて +/use italiapersonalfinance +dammi le ultime news +``` + ### 統一コマンド実行ポリシー - 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。 -- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時にサポートするコマンドメニューを自動登録します。 +- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時に `/start`、`/help`、`/show`、`/list`、`/use`、`/btw` などのサポート済みコマンドを自動登録します。 - 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。 - 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。 @@ -320,7 +340,7 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [取得](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [取得](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [取得](https://console.groq.com) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | @@ -350,9 +370,12 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 PicoClaw はプロトコルファミリーで Provider をルーティングします: - **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。 +- **Gemini ネイティブ**:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - **Anthropic**:Claude ネイティブ API の動作。 - **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。 +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現します。 + ### スケジュールタスク / リマインダー PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。 @@ -374,7 +397,7 @@ PicoClaw は `cron` ツールを通じて cron スタイルのスケジュール | トピック | 説明 | | -------- | ---- | -| [Hook システム](../hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | -| [Steering](../steering.md) | 実行中の Agent ループにメッセージを注入 | -| [SubTurn](../subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | -| [コンテキスト管理](../agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 | +| [Hook システム](../architecture/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | +| [Steering](../architecture/steering.md) | 実行中の Agent ループにメッセージを注入 | +| [SubTurn](../architecture/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | +| [コンテキスト管理](../architecture/agent-refactor/context.md) | コンテキスト境界検出、圧縮戦略 | diff --git a/docs/configuration.md b/docs/guides/configuration.md similarity index 79% rename from docs/configuration.md rename to docs/guides/configuration.md index e59d6a022..28fc7b775 100644 --- a/docs/configuration.md +++ b/docs/guides/configuration.md @@ -6,7 +6,7 @@ Config file: `~/.picoclaw/config.json` -> **Security Configuration:** For storing API keys, tokens, and other sensitive data, see the [Security Configuration Guide](security_configuration.md). +> **Security Configuration:** For storing API keys, tokens, and other sensitive data, see the [Security Configuration Guide](../security/security_configuration.md). ### Environment Variables @@ -71,15 +71,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ### Web launcher dashboard -**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used). - -**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**. +**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`. - **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`. -- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header. +- **Password storage**: On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. On platforms where the SQLite password store is unavailable, the bcrypt hash is stored in `launcher-config.json`. +- **Legacy migration**: Older `launcher_token` values are migrated once into password login and removed from saved launcher config. +- **Local auto-login**: When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- **Unsupported auth paths**: URL token login (`?token=...`), `PICOCLAW_LAUNCHER_TOKEN`, and `Authorization: Bearer` dashboard auth are no longer supported. - **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern). - **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded). -- **Session lifetime**: The HttpOnly session cookie lasts about **7 days** by default; sign in again with the token after it expires. +- **Session lifetime**: The HttpOnly session cookie lasts about **31 days** by default, but sessions are invalidated when the launcher process restarts. ### Skill Sources @@ -97,18 +98,24 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ### Using Skills From Chat Channels -Once skills are installed, you can inspect and force them directly from a chat channel: +Once skills are installed, and MCP servers are configured, you can inspect and force them directly from a chat channel: - `/list skills` shows the installed skill names available to the current agent. +- `/list mcp` shows configured MCP servers with enabled/deferred/connected status. +- `/show mcp ` shows the active tools exposed by a connected MCP server. - `/use ` forces a specific skill for a single request. - `/use ` arms that skill for your next message in the same chat session. - `/use clear` cancels a pending skill override created by `/use `. +- `/btw ` asks an immediate side question without changing the current session history. `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow. Examples: ```text /list skills +/list mcp +/show mcp github /use git explain how to squash the last 3 commits +/btw remind me what we already decided about the deploy plan /use italiapersonalfinance dammi le ultime news ``` @@ -116,10 +123,19 @@ dammi le ultime news ### Unified Command Execution Policy - Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`. -- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup. +- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands such as `/start`, `/help`, `/show`, `/list`, `/use`, and `/btw` at startup. - Unknown slash command (for example `/foo`) passes through to normal LLM processing. - Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing. +### Session Isolation + +Session scope controls how much memory is shared between chats, users, threads, and spaces. + +- Use `session.dimensions` for the global default. +- Use `session_dimensions` on a dispatch rule for one routed exception. + +For step-by-step recipes and isolation patterns, see the [Session Guide](session-guide.md). + ### Routing Routing is configured through `agents.dispatch.rules`. @@ -193,6 +209,8 @@ In the example above, the VIP rule must appear before the broader group rule. Because routing is strictly ordered, more specific rules should be placed earlier and broader fallback rules later. +For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md). + ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. @@ -480,7 +498,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate ### Model Configuration (model_list) -> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers — **zero code changes required!** +> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted. This design also enables **multi-agent support** with flexible provider selection: @@ -533,7 +551,8 @@ chmod 600 ~/.picoclaw/.security.yml "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4" + "provider": "openai", + "model": "gpt-5.4" // api_key loaded from .security.yml } ], @@ -553,35 +572,35 @@ chmod 600 ~/.picoclaw/.security.yml - If a field exists in both files, `.security.yml` value takes precedence - You can mix direct values in config.json with security values -For complete documentation, see [`security_configuration.md`](security_configuration.md). +For complete documentation, see [`../security/security_configuration.md`](../security/security_configuration.md). #### All Supported Vendors -| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| Vendor | `provider` Value | Default API Base | Protocol | API Key | | ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | -| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | -| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | -| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — | -| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | -| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | -| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | -| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **VolcEngine (Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | +| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — | #### Basic Configuration @@ -590,22 +609,26 @@ For complete documentation, see [`security_configuration.md`](security_configura "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-zhipu-key"] } ], @@ -621,6 +644,13 @@ For complete documentation, see [`security_configuration.md`](security_configura > > **Note**: The `enabled` field can be set to `false` to disable a model entry without removing it. When omitted, it defaults to `true` during migration for models that have API keys. +Resolution rules: + +- Prefer explicit `"provider": "openai", "model": "gpt-5.4"`. +- If `provider` is set, PicoClaw sends `model` unchanged. +- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID. +- This means `"model": "openrouter/openai/gpt-5.4"` still works as a compatibility form and sends `openai/gpt-5.4` to OpenRouter. + #### Vendor-Specific Examples > **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended). @@ -631,7 +661,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4" + "provider": "openai", + "model": "gpt-5.4" // api_key: set in .security.yml } ``` @@ -644,7 +675,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest" + "provider": "volcengine", + "model": "ark-code-latest" // api_key: set in .security.yml } ``` @@ -657,7 +689,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7" + "provider": "zhipu", + "model": "glm-4.7" // api_key: set in .security.yml } ``` @@ -670,7 +703,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat" + "provider": "deepseek", + "model": "deepseek-chat" // api_key: set in .security.yml } ``` @@ -683,7 +717,8 @@ For complete documentation, see [`security_configuration.md`](security_configura ```json { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6" + "provider": "anthropic", + "model": "claude-sonnet-4.6" // api_key: set in .security.yml } ``` @@ -695,7 +730,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } @@ -711,7 +747,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "llama3", - "model": "ollama/llama3" + "provider": "ollama", + "model": "llama3" } ``` @@ -723,12 +760,13 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "lmstudio-local", - "model": "lmstudio/openai/gpt-oss-20b" + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" } ``` `api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
-PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. +With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to LM Studio. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted. @@ -738,13 +776,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio ```json { "model_name": "my-custom-model", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1" // api_key: set in .security.yml } ``` -PicoClaw strips only the outer `litellm/` prefix before sending the request, so `litellm/lite-gpt4` sends `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`. +With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted. @@ -769,7 +808,8 @@ model_list: "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api.openai.com/v1" // api_keys loaded from .security.yml } @@ -784,13 +824,15 @@ model_list: "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } @@ -807,6 +849,7 @@ The old `providers` configuration is **deprecated** and has been removed in V2. PicoClaw routes providers by protocol family: - **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others. +- **Gemini native**: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - **Anthropic**: Claude-native API behavior. - **Codex/OAuth**: OpenAI OAuth/token authentication route. @@ -823,7 +866,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m "model": "glm-4.7", "max_tokens": 8192, "temperature": 0.7, - "max_tool_iterations": 20 + "max_tool_iterations": 20, + "max_parallel_turns": 1 } }, "providers": { @@ -836,6 +880,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m ``` > **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security. +> +> **`max_parallel_turns`**: Controls concurrent processing of messages from different sessions. `1` (default) = sequential; `>1` = parallel. Messages from the same session are always serialized. See [Steering docs](../architecture/steering.md) for details. @@ -846,7 +892,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { @@ -901,9 +947,9 @@ Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace | Topic | Description | | ----- | ----------- | -| [Security Configuration](security_configuration.md) | Store API keys and secrets in separate `.security.yml` file | -| [Sensitive Data Filtering](sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | -| [Hook System](hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | -| [Steering](steering.md) | Inject messages into a running agent loop between tool calls | -| [SubTurn](subturn.md) | Subagent coordination, concurrency control, lifecycle | -| [Context Management](agent-refactor/context.md) | Context boundary detection, proactive budget check, compression | +| [Security Configuration](../security/security_configuration.md) | Store API keys and secrets in separate `.security.yml` file | +| [Sensitive Data Filtering](../security/sensitive_data_filtering.md) | Filter API keys and tokens from tool results before sending to LLM | +| [Hook System](../architecture/hooks/README.md) | Event-driven hooks: observers, interceptors, approval hooks | +| [Steering](../architecture/steering.md) | Inject messages into a running agent loop between tool calls | +| [SubTurn](../architecture/subturn.md) | Subagent coordination, concurrency control, lifecycle | +| [Context Management](../architecture/agent-refactor/context.md) | Context boundary detection, proactive budget check, compression | diff --git a/docs/my/configuration.md b/docs/guides/configuration.ms.md similarity index 90% rename from docs/my/configuration.md rename to docs/guides/configuration.ms.md index f798bd9bd..bcd17afa8 100644 --- a/docs/my/configuration.md +++ b/docs/guides/configuration.ms.md @@ -1,6 +1,6 @@ # ⚙️ Panduan Konfigurasi -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## ⚙️ Konfigurasi @@ -63,10 +63,30 @@ Untuk setup lanjutan/ujian, anda boleh menindih root builtin skills dengan: export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Menggunakan Skill dan Arahan Dari Saluran Chat + +Selepas skill dipasang, anda boleh menyemak dan memaksanya terus dari saluran chat: + +- `/list skills` memaparkan nama skill dipasang yang kelihatan kepada agen semasa. +- `/use ` memaksa satu skill untuk satu permintaan sahaja. +- `/use ` menyediakan skill itu untuk mesej anda yang seterusnya dalam chat yang sama. +- `/use clear` membatalkan skill override tertunda yang dibuat melalui `/use `. +- `/btw ` bertanya soalan sampingan segera tanpa mengubah sejarah sesi semasa. `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa. + +Contoh: + +```text +/list skills +/use git terangkan cara squash 3 commit terakhir +/btw ingatkan saya semula apa keputusan tadi untuk pelan deploy +/use italiapersonalfinance +dammi le ultime news +``` + ### Polisi Pelaksanaan Arahan Bersepadu - Generic slash command dilaksanakan melalui satu laluan dalam `pkg/agent/loop.go` melalui `commands.Executor`. -- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup. +- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup seperti `/start`, `/help`, `/show`, `/list`, `/use`, dan `/btw`. - Slash command yang tidak dikenali (contohnya `/foo`) akan diteruskan ke pemprosesan LLM biasa. - Arahan yang didaftarkan tetapi tidak disokong pada saluran semasa (contohnya `/show` di WhatsApp) akan memulangkan ralat yang jelas kepada pengguna dan menghentikan pemprosesan lanjut. diff --git a/docs/pt-br/configuration.md b/docs/guides/configuration.pt-br.md similarity index 90% rename from docs/pt-br/configuration.md rename to docs/guides/configuration.pt-br.md index 27cd6d21f..e5d904e29 100644 --- a/docs/pt-br/configuration.md +++ b/docs/guides/configuration.pt-br.md @@ -1,6 +1,6 @@ # ⚙️ Guia de Configuração -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## ⚙️ Configuração @@ -81,10 +81,30 @@ Para configurações avançadas/de teste, você pode substituir o diretório rai export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Usando Skills e Comandos em Canais de Chat + +Depois que as skills estiverem instaladas, voce pode inspeciona-las e aplica-las diretamente de um canal de chat: + +- `/list skills` mostra os nomes das skills instaladas visiveis para o agente atual. +- `/use ` força uma skill para uma unica requisicao. +- `/use ` prepara essa skill para a sua proxima mensagem no mesmo chat. +- `/use clear` cancela uma substituicao pendente criada por `/use `. +- `/btw ` faz uma pergunta lateral imediata sem alterar o historico atual da sessao. `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas. + +Exemplos: + +```text +/list skills +/use git explique como fazer squash dos ultimos 3 commits +/btw me relembre o que ja decidimos sobre o plano de deploy +/use italiapersonalfinance +dammi le ultime news +``` + ### Política Unificada de Execução de Comandos - Comandos slash genéricos são executados através de um único caminho em `pkg/agent/loop.go` via `commands.Executor`. -- Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente os comandos suportados na inicialização. +- Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente na inicialização comandos suportados como `/start`, `/help`, `/show`, `/list`, `/use` e `/btw`. - Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM. - Comando registrado mas não suportado no canal atual (por exemplo `/show` no WhatsApp) retorna um erro explícito ao usuário e interrompe o processamento. @@ -320,7 +340,7 @@ Responde HEARTBEAT_OK Usuário recebe resultado diretamente | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obter](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) | @@ -350,9 +370,12 @@ A configuração antiga `providers` está **depreciada** e foi removida no V2. C PicoClaw roteia providers por família de protocolo: - **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros. +- **Gemini nativo**: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - **Anthropic**: Comportamento nativo da API Claude. - **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI. +Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`). + ### Tarefas Agendadas / Lembretes PicoClaw suporta tarefas agendadas via ferramenta `cron`. @@ -374,7 +397,7 @@ As tarefas agendadas persistem após reinicializações em `~/.picoclaw/workspac | Tópico | Descrição | | ------ | --------- | -| [Sistema de Hooks](../hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação | -| [Steering](../steering.md) | Injetar mensagens em um loop de agente em execução | -| [SubTurn](../subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | -| [Gerenciamento de Contexto](../agent-refactor/context.md) | Detecção de limites de contexto, compressão | +| [Sistema de Hooks](../architecture/hooks/README.md) | Hooks orientados a eventos: observadores, interceptores, hooks de aprovação | +| [Steering](../architecture/steering.md) | Injetar mensagens em um loop de agente em execução | +| [SubTurn](../architecture/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | +| [Gerenciamento de Contexto](../architecture/agent-refactor/context.md) | Detecção de limites de contexto, compressão | diff --git a/docs/vi/configuration.md b/docs/guides/configuration.vi.md similarity index 91% rename from docs/vi/configuration.md rename to docs/guides/configuration.vi.md index 56eb8f557..d905b6d2b 100644 --- a/docs/vi/configuration.md +++ b/docs/guides/configuration.vi.md @@ -1,6 +1,6 @@ # ⚙️ Hướng Dẫn Cấu Hình -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## ⚙️ Cấu Hình @@ -81,10 +81,30 @@ Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skil export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ``` +### Dung Skill va Lenh Tu Kenh Chat + +Sau khi cai dat skill, ban co the xem va ep dung truc tiep tu kenh chat: + +- `/list skills` hien ten cac skill da cai dat ma agent hien tai co the dung. +- `/use ` ep dung mot skill cho duy nhat mot yeu cau. +- `/use ` dat san skill do cho tin nhan tiep theo trong cung cuoc tro chuyen. +- `/use clear` huy skill override dang cho duoc tao boi `/use `. +- `/btw ` dat cau hoi phu ngay lap tuc ma khong thay doi lich su phien hien tai. `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong. + +Vi du: + +```text +/list skills +/use git giai thich cach squash 3 commit cuoi +/btw nhac lai giup toi chung ta da chot gi cho ke hoach deploy +/use italiapersonalfinance +dammi le ultime news +``` + ### Chính Sách Thực Thi Lệnh Thống Nhất - Lệnh slash chung được thực thi qua một đường dẫn duy nhất trong `pkg/agent/loop.go` qua `commands.Executor`. -- Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký lệnh được hỗ trợ khi khởi động. +- Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký khi khởi động các lệnh được hỗ trợ như `/start`, `/help`, `/show`, `/list`, `/use`, va `/btw`. - Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường. - Lệnh đã đăng ký nhưng không được hỗ trợ trên kênh hiện tại (ví dụ `/show` trên WhatsApp) trả về lỗi rõ ràng cho người dùng và dừng xử lý tiếp. @@ -320,7 +340,7 @@ Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Lấy](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) | @@ -350,9 +370,12 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác. +- **Gemini native**: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - **Anthropic**: Hành vi API Claude gốc. - **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI. +Điều này giữ runtime nhẹ trong khi khiến backend OpenAI-compatible mới chủ yếu chỉ là thao tác cấu hình (`api_base` + `api_keys`). + ### Tác Vụ Đã Lên Lịch / Nhắc Nhở PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`. @@ -374,7 +397,7 @@ Tác vụ đã lên lịch được lưu trữ bền vững sau khi khởi độ | Chủ đề | Mô tả | | ------ | ----- | -| [Hệ Thống Hook](../hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook | -| [Steering](../steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | -| [SubTurn](../subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | -| [Quản Lý Ngữ Cảnh](../agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén | +| [Hệ Thống Hook](../architecture/hooks/README.md) | Hook hướng sự kiện: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | +| [SubTurn](../architecture/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | +| [Quản Lý Ngữ Cảnh](../architecture/agent-refactor/context.md) | Phát hiện ranh giới ngữ cảnh, nén | diff --git a/docs/zh/configuration.md b/docs/guides/configuration.zh.md similarity index 72% rename from docs/zh/configuration.md rename to docs/guides/configuration.zh.md index a628eaaa2..dbc853d98 100644 --- a/docs/zh/configuration.md +++ b/docs/guides/configuration.zh.md @@ -1,6 +1,6 @@ # ⚙️ 配置指南 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## ⚙️ 配置详解 @@ -69,15 +69,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ### Web 启动器控制台 -用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。 - -**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。 +用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 - **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。 -- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。 +- **密码存储**:支持的平台会把 bcrypt 后的密码哈希存入 `launcher-auth.db`。如果当前平台不支持 SQLite 密码存储,则把 bcrypt 哈希存入 `launcher-config.json`。 +- **旧配置迁移**:旧版 `launcher_token` 会一次性迁移为密码登录,并从保存后的 launcher 配置中移除。 +- **本地自动登录**:launcher 启动后自动打开本地浏览器时,会使用仅允许 loopback 访问的一次性引导入口自动设置会话 Cookie。 +- **不再支持的鉴权方式**:不再支持 URL token 登录(`?token=...`)、`PICOCLAW_LAUNCHER_TOKEN` 和 `Authorization: Bearer` dashboard 鉴权。 - **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。 - **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。 -- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。 +- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效。 ### 技能来源 (Skill Sources) @@ -101,12 +102,14 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills - `/use `:只对当前这一条请求强制使用指定技能。 - `/use `:为同一会话中的下一条消息预先启用该技能。 - `/use clear`:取消通过 `/use ` 设置的待应用技能。 +- `/btw `:发起一个即时的旁支提问,且不改动当前会话历史。`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程。 示例: ```text /list skills /use git explain how to squash the last 3 commits +/btw 帮我回顾一下刚才关于发布方案的结论 /use italiapersonalfinance dammi le ultime news ``` @@ -114,10 +117,90 @@ dammi le ultime news ### 统一命令执行策略 - 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。 -- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。 +- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单,例如 `/start`、`/help`、`/show`、`/list`、`/use` 和 `/btw`。 - 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。 - 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。 +### Session 隔离 + +Session scope 决定了聊天、用户、线程和 space 之间共享多少上下文。 + +- 全局默认值使用 `session.dimensions` +- 如果只想让某条路由例外,使用 dispatch rule 上的 `session_dimensions` + +如果你想看完整的隔离方案和配置配方,请看 [Session 使用指南](session-guide.zh.md)。 + +### Routing + +Routing 通过 `agents.dispatch.rules` 配置。 + +每条规则都针对 channel 归一化后的 inbound context 做匹配。 +规则按从上到下顺序检查,第一条命中的规则立即生效。若没有规则命中,PicoClaw 会回退到默认 agent。 + +支持的匹配字段: + +* `channel` +* `account` +* `space` +* `chat` +* `topic` +* `sender` +* `mentioned` + +这些值使用和 session system 一致的归一化词汇: + +* `space`: `workspace:t001`、`guild:123456` +* `chat`: `direct:user123`、`group:-100123`、`channel:c123` +* `topic`: `topic:42` +* `sender`: 平台归一化后的 sender 标识 + +规则也可以通过 `session_dimensions` 覆盖全局 `session.dimensions`,这样路由和会话隔离就能保持一致,而不必回到旧的 `bindings` 或 `dm_scope` 配置。 + +示例: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个例子里,VIP 规则必须放在更宽泛的群规则前面。 +因为 routing 是严格按顺序执行的,所以更具体的规则要放前面,兜底规则放后面。 + +如果你想看更完整的 agent 路由和模型分层示例,请看 [路由使用指南](routing-guide.zh.md)。 + ### 🔒 安全沙箱 (Security Sandbox) PicoClaw 默认在沙箱环境中运行。Agent 只能访问配置的工作区内的文件和执行命令。 @@ -342,7 +425,7 @@ Agent 读取 HEARTBEAT.md ### 模型配置 (model_list) -> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!** +> **新特性:** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。 这一设计同时支持**多 Agent**场景,灵活选择提供商: @@ -353,31 +436,31 @@ Agent 读取 HEARTBEAT.md #### 所有支持的厂商 -| 厂商 | `model` 前缀 | 默认 API Base | 协议 | API Key | +| 厂商 | `provider` 值 | 默认 API Base | 协议 | API Key | | ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) | -| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) | -| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) | -| **火山引擎 (豆包)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — | -| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) | -| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) | -| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) | -| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | 仅 OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) | +| **火山引擎 (豆包)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) | +| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) | +| **Antigravity** | `antigravity` | Google Cloud | Custom | 仅 OAuth | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — | #### 基础配置 @@ -386,22 +469,26 @@ Agent 读取 HEARTBEAT.md "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-zhipu-key"] } ], @@ -413,6 +500,13 @@ Agent 读取 HEARTBEAT.md } ``` +解析规则: + +- 推荐显式写成 `"provider": "openai", "model": "gpt-5.4"`。 +- 如果设置了 `provider`,PicoClaw 会将 `model` 原样发送。 +- 如果未设置 `provider`,PicoClaw 会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终模型 ID。 +- 这意味着 `"model": "openrouter/openai/gpt-5.4"` 这样的兼容写法仍然可用,并会把 `openai/gpt-5.4` 发送给 OpenRouter。 + #### 各厂商配置示例

@@ -421,7 +515,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-..."] } ``` @@ -434,7 +529,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-..."] } ``` @@ -447,7 +543,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-key"] } ``` @@ -460,7 +557,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", "api_keys": ["sk-..."] } ``` @@ -473,7 +571,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["sk-ant-your-key"] } ``` @@ -485,7 +584,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } @@ -501,7 +601,8 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "llama3", - "model": "ollama/llama3" + "provider": "ollama", + "model": "llama3" } ``` @@ -513,12 +614,13 @@ Agent 读取 HEARTBEAT.md ```json { "model_name": "lmstudio-local", - "model": "lmstudio/openai/gpt-oss-20b" + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" } ``` `api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 -PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 +显式设置 `provider` 后,PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID。
@@ -528,13 +630,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首 ```json { "model_name": "my-custom-model", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."] } ``` -PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litellm/lite-gpt4` 发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 发送 `openai/gpt-4o`。 +显式设置 `provider` 后,PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4` 和 `litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果。 @@ -547,13 +650,15 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } @@ -570,10 +675,11 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell PicoClaw 按协议族路由提供商: - **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。 +- **Gemini 原生**:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - **Anthropic**:Claude 原生 API 行为。 - **Codex/OAuth**:OpenAI OAuth/Token 认证路由。 -这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。 +这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。
智谱(旧版 providers 格式) @@ -607,7 +713,7 @@ PicoClaw 按协议族路由提供商: { "agents": { "defaults": { - "model": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { @@ -668,8 +774,8 @@ PicoClaw 通过 `cron` 工具支持 cron 风格的定时任务。Agent 可以设 | 主题 | 说明 | | ---- | ---- | -| [敏感数据过滤](../sensitive_data_filtering.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | -| [Hook 系统](../hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | -| [Steering](../steering.md) | 在工具调用间向运行中的 Agent 注入消息 | -| [SubTurn](../subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | -| [上下文管理](../agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 | +| [敏感数据过滤](../security/sensitive_data_filtering.zh.md) | 在发送给 LLM 前,从工具结果中过滤 API 密钥和令牌 | +| [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | +| [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | +| [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | +| [上下文管理](../architecture/agent-refactor/context.md) | 上下文边界检测、主动预算检查、压缩策略 | diff --git a/docs/fr/docker.md b/docs/guides/docker.fr.md similarity index 96% rename from docs/fr/docker.md rename to docs/guides/docker.fr.md index 9605440bc..ed0d14cf3 100644 --- a/docs/fr/docker.md +++ b/docs/guides/docker.fr.md @@ -1,6 +1,6 @@ # 🐳 Docker et Démarrage Rapide -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## 🐳 Docker Compose @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway. > [!WARNING] -> La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public. +> La console web est protégée par un mot de passe de connexion au dashboard. Ne l'exposez pas à des réseaux non fiables ni à Internet public. ### Mode Agent (One-shot) diff --git a/docs/ja/docker.md b/docs/guides/docker.ja.md similarity index 94% rename from docs/ja/docker.md rename to docs/guides/docker.ja.md index a585c5e80..8fa5ae60c 100644 --- a/docs/ja/docker.md +++ b/docs/guides/docker.ja.md @@ -1,6 +1,6 @@ # 🐳 Docker とクイックスタート -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## 🐳 Docker Compose @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 > [!WARNING] -> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。 +> Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットには公開しないでください。 ### Agent モード (ワンショット) @@ -143,7 +143,7 @@ picoclaw onboard } ``` -> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.md#モデル設定-model_list)を参照してください。 +> **新機能**: `model_list` 設定形式により、コード変更なしで provider を追加できます。詳細は[モデル設定](providers.ja.md#モデル設定-model_list)を参照してください。 > `request_timeout` はオプションで、単位は秒です。省略または `<= 0` に設定した場合、PicoClaw はデフォルトのタイムアウト(120 秒)を使用します。 **3. API Key の取得** diff --git a/docs/docker.md b/docs/guides/docker.md similarity index 90% rename from docs/docker.md rename to docs/guides/docker.md index 6c32879a6..e017538f7 100644 --- a/docs/docker.md +++ b/docs/guides/docker.md @@ -27,7 +27,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. > [!NOTE] -> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/token` and a `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. +> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/info` and an authenticated `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. ```bash # 5. Check logs @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. > [!WARNING] -> The web console uses a dashboard token (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide. +> The web console is protected by dashboard password login. **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide. ### Agent Mode (One-shot) @@ -94,19 +94,22 @@ picoclaw onboard "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["your-anthropic-key"] } ], diff --git a/docs/my/docker.md b/docs/guides/docker.ms.md similarity index 96% rename from docs/my/docker.md rename to docs/guides/docker.ms.md index 2f9cac3fd..7adab6759 100644 --- a/docs/my/docker.md +++ b/docs/guides/docker.ms.md @@ -1,6 +1,6 @@ # 🐳 Panduan Docker & Quick Start -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## 🐳 Docker Compose @@ -44,7 +44,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik. > [!WARNING] -> Konsol web belum menyokong autentikasi. Elakkan mendedahkannya ke internet awam. +> Konsol web dilindungi oleh kata laluan log masuk dashboard. Jangan dedahkannya kepada rangkaian tidak dipercayai atau internet awam. ### Mod Agent (One-shot) diff --git a/docs/pt-br/docker.md b/docs/guides/docker.pt-br.md similarity index 96% rename from docs/pt-br/docker.md rename to docs/guides/docker.pt-br.md index a17dc64ec..d7d55e753 100644 --- a/docs/pt-br/docker.md +++ b/docs/guides/docker.pt-br.md @@ -1,6 +1,6 @@ # 🐳 Docker e Início Rápido -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## 🐳 Docker Compose @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente. > [!WARNING] -> O console web ainda não suporta autenticação. Evite expô-lo na internet pública. +> O console web é protegido por senha de login do dashboard. Não exponha o launcher a redes não confiáveis nem à internet pública. ### Modo Agent (One-shot) diff --git a/docs/vi/docker.md b/docs/guides/docker.vi.md similarity index 96% rename from docs/vi/docker.md rename to docs/guides/docker.vi.md index e6bc74b1a..05f1b3d68 100644 --- a/docs/vi/docker.md +++ b/docs/guides/docker.vi.md @@ -1,6 +1,6 @@ # 🐳 Docker và Bắt Đầu Nhanh -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## 🐳 Docker Compose @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway. > [!WARNING] -> Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng. +> Web console được bảo vệ bằng mật khẩu đăng nhập dashboard. Không để lộ launcher ra mạng không tin cậy hoặc internet công cộng. ### Chế Độ Agent (One-shot) diff --git a/docs/zh/docker.md b/docs/guides/docker.zh.md similarity index 90% rename from docs/zh/docker.md rename to docs/guides/docker.zh.md index f840290a7..bed445751 100644 --- a/docs/zh/docker.md +++ b/docs/guides/docker.zh.md @@ -1,6 +1,6 @@ # 🐳 Docker 与快速开始 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ## 🐳 Docker Compose @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d 在浏览器中打开 。Launcher 会自动管理 Gateway 进程。 > [!WARNING] -> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 +> Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 ### Agent 模式 (一次性运行) @@ -93,19 +93,22 @@ picoclaw onboard "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-your-api-key"], "api_base":"https://ark.cn-beijing.volces.com/api/coding/v3" }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["your-api-key"], "request_timeout": 300 }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["your-anthropic-key"] } ], @@ -143,7 +146,7 @@ picoclaw onboard } ``` -> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.md#模型配置-model_list)章节。 +> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](providers.zh.md#模型配置-model_list)章节。 > `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。 **3. 获取 API Key** diff --git a/docs/fr/hardware-compatibility.md b/docs/guides/hardware-compatibility.fr.md similarity index 98% rename from docs/fr/hardware-compatibility.md rename to docs/guides/hardware-compatibility.fr.md index c1f397e80..bb2d92d57 100644 --- a/docs/fr/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # 🖥️ PicoClaw Liste de compatibilité matérielle @@ -99,7 +99,7 @@ Produits grand public, routeurs et appareils industriels testés avec PicoClaw. Tout téléphone Android ARM64 (2015+) avec 1 Go+ de RAM. Installez [Termux](https://github.com/termux/termux-app), utilisez `proot` pour exécuter PicoClaw. -> Voir [README : Exécuter sur d'anciens téléphones Android](../../README.fr.md#-run-on-old-android-phones) pour les instructions de configuration. +> Voir [README : Exécuter sur d'anciens téléphones Android](../project/README.fr.md#-run-on-old-android-phones) pour les instructions de configuration. ### Bureau / Serveur / Cloud diff --git a/docs/ja/hardware-compatibility.md b/docs/guides/hardware-compatibility.ja.md similarity index 98% rename from docs/ja/hardware-compatibility.md rename to docs/guides/hardware-compatibility.ja.md index 96ccd1cd1..c86684f84 100644 --- a/docs/ja/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # 🖥️ PicoClaw ハードウェア互換性リスト @@ -99,7 +99,7 @@ PicoClaw でテスト済みのコンシューマー製品、ルーター、産 1GB 以上の RAM を搭載した ARM64 Android スマートフォン(2015年以降)。[Termux](https://github.com/termux/termux-app) をインストールし、`proot` を使用して PicoClaw を実行します。 -> セットアップ手順は [README:古い Android スマートフォンで実行](../../README.ja.md#-run-on-old-android-phones) を参照してください。 +> セットアップ手順は [README:古い Android スマートフォンで実行](../project/README.ja.md#-run-on-old-android-phones) を参照してください。 ### デスクトップ / サーバー / クラウド diff --git a/docs/hardware-compatibility.md b/docs/guides/hardware-compatibility.md similarity index 98% rename from docs/hardware-compatibility.md rename to docs/guides/hardware-compatibility.md index c11849822..a07bb5116 100644 --- a/docs/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.md @@ -97,7 +97,7 @@ Consumer products, routers, and industrial devices that have been tested with Pi Any ARM64 Android phone (2015+) with 1GB+ RAM. Install [Termux](https://github.com/termux/termux-app), use `proot` to run PicoClaw. -> See [README: Run on old Android Phones](../README.md#-run-on-old-android-phones) for setup instructions. +> See [README: Run on old Android Phones](../../README.md#-run-on-old-android-phones) for setup instructions. ### Desktop / Server / Cloud diff --git a/docs/pt-br/hardware-compatibility.md b/docs/guides/hardware-compatibility.pt-br.md similarity index 97% rename from docs/pt-br/hardware-compatibility.md rename to docs/guides/hardware-compatibility.pt-br.md index 771621014..1fc8ee25e 100644 --- a/docs/pt-br/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # 🖥️ PicoClaw Lista de compatibilidade de hardware @@ -99,7 +99,7 @@ Produtos de consumo, roteadores e dispositivos industriais testados com o PicoCl Qualquer celular Android ARM64 (2015+) com 1GB+ de RAM. Instale o [Termux](https://github.com/termux/termux-app), use `proot` para rodar o PicoClaw. -> Veja [README: Rodar em celulares Android antigos](../../README.pt-br.md#-run-on-old-android-phones) para instruções de configuração. +> Veja [README: Rodar em celulares Android antigos](../project/README.pt-br.md#-run-on-old-android-phones) para instruções de configuração. ### Desktop / Servidor / Nuvem diff --git a/docs/vi/hardware-compatibility.md b/docs/guides/hardware-compatibility.vi.md similarity index 97% rename from docs/vi/hardware-compatibility.md rename to docs/guides/hardware-compatibility.vi.md index 8315c049e..5566a4248 100644 --- a/docs/vi/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # 🖥️ PicoClaw Danh sách tương thích phần cứng @@ -99,7 +99,7 @@ Sản phẩm tiêu dùng, router và thiết bị công nghiệp đã được k Bất kỳ điện thoại Android ARM64 nào (2015+) với 1GB+ RAM. Cài đặt [Termux](https://github.com/termux/termux-app), sử dụng `proot` để chạy PicoClaw. -> Xem [README: Chạy trên điện thoại Android cũ](../../README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt. +> Xem [README: Chạy trên điện thoại Android cũ](../project/README.vi.md#-run-on-old-android-phones) để biết hướng dẫn cài đặt. ### Desktop / Máy chủ / Đám mây diff --git a/docs/zh/hardware-compatibility.md b/docs/guides/hardware-compatibility.zh.md similarity index 97% rename from docs/zh/hardware-compatibility.md rename to docs/guides/hardware-compatibility.zh.md index 66bd08072..d563f3ebe 100644 --- a/docs/zh/hardware-compatibility.md +++ b/docs/guides/hardware-compatibility.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 🖥️ PicoClaw 硬件兼容性列表 @@ -99,7 +99,7 @@ PicoClaw 几乎可以在任何 Linux 设备上运行。本页面记录了已验 任何 ARM64 Android 手机(2015 年以后),1GB 以上内存。安装 [Termux](https://github.com/termux/termux-app),使用 `proot` 运行 PicoClaw。 -> 参见 [README:在旧 Android 手机上运行](../../README.zh.md#-run-on-old-android-phones) 获取设置说明。 +> 参见 [README:在旧 Android 手机上运行](../project/README.zh.md#-run-on-old-android-phones) 获取设置说明。 ### 桌面 / 服务器 / 云 diff --git a/docs/fr/providers.md b/docs/guides/providers.fr.md similarity index 97% rename from docs/fr/providers.md rename to docs/guides/providers.fr.md index f053d5d57..aff600351 100644 --- a/docs/fr/providers.md +++ b/docs/guides/providers.fr.md @@ -1,6 +1,6 @@ # 🔌 Fournisseurs et Configuration des Modèles -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ### Fournisseurs @@ -46,7 +46,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) | | `api_base` | string | Non | Remplace l'URL de base API par défaut | | `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle | -| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) | +| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers compatibles OpenAI, Gemini, Anthropic et Azure) | | `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) | | `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) | | `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | @@ -299,10 +299,11 @@ Pour un guide de migration détaillé, voir [migration/model-list-migration.md]( PicoClaw route les fournisseurs par famille de protocoles : - Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. +- Protocole Gemini natif : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - Protocole Anthropic : Comportement natif de l'API Claude. - Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI. -Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_key`). +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`).
Zhipu @@ -454,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/ja/providers.md b/docs/guides/providers.ja.md similarity index 97% rename from docs/ja/providers.md rename to docs/guides/providers.ja.md index b22e1f7ba..fecc74519 100644 --- a/docs/ja/providers.md +++ b/docs/guides/providers.ja.md @@ -1,6 +1,6 @@ # 🔌 プロバイダーとモデル設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ### プロバイダー @@ -27,6 +27,7 @@ | `longcat` | LLM (Longcat 直接接続) | [longcat.ai](https://longcat.ai) | | `modelscope` | LLM (ModelScope 直接接続) | [modelscope.cn](https://modelscope.cn) | + ### モデル設定 (model_list) > **新機能!** PicoClaw は**モデル中心**の設定方式を採用しました。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで新しい provider を追加できます——**コード変更は一切不要です!** @@ -46,7 +47,7 @@ | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [キーを取得](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) | @@ -108,7 +109,7 @@ | `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 | | `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | | `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL | -| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) | +| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Gemini、Anthropic、Azure provider で対応) | | `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる | | `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) | | `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` | @@ -310,6 +311,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック PicoClaw はプロトコルファミリーごとに Provider をルーティングします: - OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 +- Gemini ネイティブプロトコル:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - Anthropic プロトコル:Claude ネイティブ API 動作。 - Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 @@ -465,5 +467,5 @@ picoclaw agent -m "こんにちは" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/providers.md b/docs/guides/providers.md similarity index 75% rename from docs/providers.md rename to docs/guides/providers.md index ca1678c7e..d99d8c016 100644 --- a/docs/providers.md +++ b/docs/guides/providers.md @@ -33,7 +33,9 @@ ### Model Configuration (model_list) -> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!** +> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted. + +For agent dispatch and light-model routing examples, see the [Routing Guide](routing-guide.md). This design also enables **multi-agent support** with flexible provider selection: @@ -44,35 +46,35 @@ This design also enables **multi-agent support** with flexible provider selectio #### 📋 All Supported Vendors -| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| Vendor | `provider` Value | Default API Base | Protocol | API Key | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | -| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | -| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | -| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | -| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | -| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | -| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | -| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | -| **Xiaomi MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) | -| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **VolcEngine (Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) | +| **Xiaomi MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) | +| **Azure OpenAI** | `azure` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) | +| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - | #### Basic Configuration @@ -81,22 +83,26 @@ This design also enables **multi-agent support** with flexible provider selectio "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-zhipu-key"] } ], @@ -113,11 +119,12 @@ This design also enables **multi-agent support** with flexible provider selectio | Field | Type | Required | Description | |-------|------|----------|-------------| | `model_name` | string | Yes | Unique name used to reference this model in agent config | -| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `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, Anthropic, and Azure providers) | +| `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` | @@ -127,6 +134,22 @@ This design also enables **multi-agent support** with flexible provider selectio | `fallbacks` | string[] | No | Fallback model names for automatic failover | | `enabled` | bool | No | Whether this model entry is active (default: `true`) | +#### Provider / Model Resolution + +PicoClaw resolves `provider` and the runtime model ID using these rules: + +- If `provider` is set, `model` is used as-is. +- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID. + +Examples: + +| Config | Resolved Provider | Model Sent Upstream | +| --- | --- | --- | +| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` | +| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` | +| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | +| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | + #### Voice Transcription You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq. @@ -138,7 +161,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to "model_list": [ { "model_name": "voice-gemini", - "model": "gemini/gemini-2.5-flash", + "provider": "gemini", + "model": "gemini-2.5-flash", "api_keys": ["your-gemini-key"] } ], @@ -161,7 +185,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-..."] } ``` @@ -171,7 +196,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-..."] } ``` @@ -181,7 +207,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-key"] } ``` @@ -191,7 +218,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "glm-4.7", - "model": "openai/glm-4.7", + "provider": "openai", + "model": "glm-4.7", "api_keys": ["your-z.ai-key"], "api_base": "https://api.z.ai/api/coding/paas/v4" } @@ -202,7 +230,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", "api_keys": ["sk-..."] } ``` @@ -212,7 +241,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to ```json { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["sk-ant-your-key"] } ``` @@ -226,7 +256,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } @@ -244,7 +275,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "llama3", - "model": "ollama/llama3" + "provider": "ollama", + "model": "llama3" } ``` @@ -253,19 +285,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' ```json { "model_name": "lmstudio-local", - "model": "lmstudio/openai/gpt-oss-20b" + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" } ``` `api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
-PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. +With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to the LM Studio server. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted. **Custom Proxy/API** ```json { "model_name": "my-custom-model", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], "user_agent": "MyApp/1.0", @@ -278,13 +312,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio ```json { "model_name": "lite-gpt4", - "model": "litellm/lite-gpt4", + "provider": "litellm", + "model": "lite-gpt4", "api_base": "http://localhost:4000/v1", "api_keys": ["sk-..."] } ``` -PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`. +With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted. **Z.AI Coding Plan** @@ -293,7 +328,8 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns ```json { "model_name": "glm-4.7", - "model": "openai/glm-4.7", + "provider": "openai", + "model": "glm-4.7", "api_keys": ["your-zhipu-api-key"], "api_base": "https://api.z.ai/api/coding/paas/v4" } @@ -310,13 +346,15 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } @@ -335,18 +373,21 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a "model_list": [ { "model_name": "qwen-main", - "model": "openai/qwen3.5:cloud", + "provider": "openai", + "model": "qwen3.5:cloud", "api_base": "https://api.example.com/v1", "api_keys": ["sk-main"] }, { "model_name": "deepseek-backup", - "model": "deepseek/deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", "api_keys": ["sk-backup-1"] }, { "model_name": "gemini-backup", - "model": "gemini/gemini-2.5-flash", + "provider": "gemini", + "model": "gemini-2.5-flash", "api_keys": ["sk-backup-2"] } ], @@ -394,7 +435,8 @@ The old `providers` configuration is **deprecated** and has been removed in V2. "model_list": [ { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-key"] } ], @@ -406,17 +448,18 @@ The old `providers` configuration is **deprecated** and has been removed in V2. } ``` -For detailed migration guide, see [migration/model-list-migration.md](migration/model-list-migration.md). +For detailed migration guide, see [migration/model-list-migration.md](../migration/model-list-migration.md). ### Provider Architecture PicoClaw routes providers by protocol family: - OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Gemini native protocol: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - Anthropic protocol: Claude-native API behavior. - Codex/OAuth path: OpenAI OAuth/token authentication route. -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
Zhipu @@ -462,7 +505,7 @@ picoclaw agent -m "Hello" { "agents": { "defaults": { - "model_name": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { @@ -572,5 +615,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/pt-br/providers.md b/docs/guides/providers.pt-br.md similarity index 98% rename from docs/pt-br/providers.md rename to docs/guides/providers.pt-br.md index ebe911b65..0d45dc309 100644 --- a/docs/pt-br/providers.md +++ b/docs/guides/providers.pt-br.md @@ -1,6 +1,6 @@ # 🔌 Provedores e Configuração de Modelos -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ### Provedores @@ -46,7 +46,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) | | `api_base` | string | Não | Substitui a URL base da API padrão | | `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo | -| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) | +| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Gemini, Anthropic e Azure) | | `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) | | `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) | | `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | @@ -299,6 +299,7 @@ Para guia de migração detalhado, veja [migration/model-list-migration.md](../m O PicoClaw roteia provedores por família de protocolo: - Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM. +- Protocolo Gemini nativo: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - Protocolo Anthropic: Comportamento nativo da API Claude. - Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. @@ -454,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/vi/providers.md b/docs/guides/providers.vi.md similarity index 98% rename from docs/vi/providers.md rename to docs/guides/providers.vi.md index 5178ad197..c354461cf 100644 --- a/docs/vi/providers.md +++ b/docs/guides/providers.vi.md @@ -1,6 +1,6 @@ # 🔌 Nhà Cung Cấp và Cấu Hình Mô Hình -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ### Nhà Cung Cấp @@ -46,7 +46,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) | | `api_base` | string | Không | Ghi đè URL endpoint API mặc định | | `proxy` | string | Không | URL proxy HTTP cho entry model này | -| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) | +| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Gemini, Anthropic và Azure) | | `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) | | `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) | | `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` | @@ -299,6 +299,7 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM. +- Giao thức Gemini native: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - Giao thức Anthropic: Hành vi API native của Claude. - Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI. @@ -454,5 +455,5 @@ picoclaw agent -m "Hello" ---
- PicoClaw Meme + PicoClaw Meme
diff --git a/docs/zh/providers.md b/docs/guides/providers.zh.md similarity index 75% rename from docs/zh/providers.md rename to docs/guides/providers.zh.md index 155fbe11b..1302407a3 100644 --- a/docs/zh/providers.md +++ b/docs/guides/providers.zh.md @@ -1,6 +1,6 @@ # 🔌 提供商与模型配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) ### 提供商 (Providers) @@ -29,9 +29,12 @@ | `modelscope` | LLM (ModelScope 直连) | [modelscope.cn](https://modelscope.cn) | | `mimo` | LLM (小米 MiMo 直连) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | + ### 模型配置 (model_list) -> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!** +> **新功能!** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。 + +如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。 该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择: @@ -42,33 +45,33 @@ #### 📋 所有支持的厂商 -| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | +| 厂商 | `provider` 值 | 默认 API Base | 协议 | 获取 API Key | | ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | -| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | -| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | -| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | -| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) | -| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) | -| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | -| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) | -| **小米 MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) | -| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | +| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | +| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) | +| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | +| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) | +| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | +| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | +| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | +| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | +| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | +| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | +| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 | +| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | +| **火山引擎(Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) | +| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) | +| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) | +| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | +| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) | +| **小米 MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) | +| **Antigravity** | `antigravity` | Google Cloud | 自定义 | 仅 OAuth | +| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - | #### 基础配置示例 @@ -77,22 +80,26 @@ "model_list": [ { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-your-api-key"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-your-openai-key"] }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["sk-ant-your-key"] }, { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-zhipu-key"] } ], @@ -109,11 +116,12 @@ | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 | -| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) | +| `provider` | string | 否 | 推荐的 provider 标识。设置后,PicoClaw 会将 `model` 原样发送给该 provider | +| `model` | string | 是 | 当设置 `provider` 时,这里填写 provider 原生模型 ID。若未设置 `provider`,仍兼容旧的 `provider/model` 写法 | | `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 | | `api_base` | string | 否 | 覆盖默认的 API 端点 URL | | `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | -| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) | +| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Gemini、Anthropic 和 Azure provider) | | `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 | | `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) | | `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` | @@ -123,6 +131,22 @@ | `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 | | `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) | +#### `provider` / `model` 解析规则 + +PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID: + +- 如果设置了 `provider`,则直接使用 `model`。 +- 如果未设置 `provider`,则把 `model` 中第一个 `/` 之前的字段当作 provider,第一个 `/` 之后的全部内容当作最终模型 ID。 + +示例: + +| 配置 | 解析后的 Provider | 实际发送的模型 ID | +| --- | --- | --- | +| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` | +| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` | +| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | +| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` | + #### 语音转录 你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。 @@ -134,7 +158,8 @@ "model_list": [ { "model_name": "voice-gemini", - "model": "gemini/gemini-2.5-flash", + "provider": "gemini", + "model": "gemini-2.5-flash", "api_keys": ["your-gemini-key"] } ], @@ -157,7 +182,8 @@ ```json { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-..."] } ``` @@ -167,7 +193,8 @@ ```json { "model_name": "ark-code-latest", - "model": "volcengine/ark-code-latest", + "provider": "volcengine", + "model": "ark-code-latest", "api_keys": ["sk-..."] } ``` @@ -177,7 +204,8 @@ ```json { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-key"] } ``` @@ -187,7 +215,8 @@ ```json { "model_name": "deepseek-chat", - "model": "deepseek/deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", "api_keys": ["sk-..."] } ``` @@ -197,7 +226,8 @@ ```json { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "auth_method": "oauth" } ``` @@ -211,7 +241,8 @@ ```json { "model_name": "claude-opus-4-6", - "model": "anthropic-messages/claude-opus-4-6", + "provider": "anthropic-messages", + "model": "claude-opus-4-6", "api_keys": ["sk-ant-your-key"], "api_base": "https://api.anthropic.com" } @@ -229,7 +260,8 @@ ```json { "model_name": "llama3", - "model": "ollama/llama3" + "provider": "ollama", + "model": "llama3" } ``` @@ -238,19 +270,21 @@ ```json { "model_name": "lmstudio-local", - "model": "lmstudio/openai/gpt-oss-20b" + "provider": "lmstudio", + "model": "openai/gpt-oss-20b" } ``` `api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 -PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 +显式设置 `provider` 后,PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID。 **自定义代理/API** ```json { "model_name": "my-custom-model", - "model": "openai/custom-model", + "provider": "openai", + "model": "custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], "user_agent": "MyApp/1.0", @@ -263,13 +297,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首 ```json { "model_name": "lite-gpt4", - "model": "litellm/lite-gpt4", + "provider": "litellm", + "model": "lite-gpt4", "api_base": "http://localhost:4000/v1", "api_keys": ["sk-..."] } ``` -PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/lite-gpt4` 会发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 会发送 `openai/gpt-4o`。 +显式设置 `provider` 后,PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4` 和 `litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果。 #### 负载均衡 @@ -280,13 +315,15 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l "model_list": [ { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] }, { "model_name": "gpt-5.4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] } @@ -305,18 +342,21 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l "model_list": [ { "model_name": "qwen-main", - "model": "openai/qwen3.5:cloud", + "provider": "openai", + "model": "qwen3.5:cloud", "api_base": "https://api.example.com/v1", "api_keys": ["sk-main"] }, { "model_name": "deepseek-backup", - "model": "deepseek/deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", "api_keys": ["sk-backup-1"] }, { "model_name": "gemini-backup", - "model": "gemini/gemini-2.5-flash", + "provider": "gemini", + "model": "gemini-2.5-flash", "api_keys": ["sk-backup-2"] } ], @@ -364,7 +404,8 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l "model_list": [ { "model_name": "glm-4.7", - "model": "zhipu/glm-4.7", + "provider": "zhipu", + "model": "glm-4.7", "api_keys": ["your-key"] } ], @@ -383,10 +424,11 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l PicoClaw 按协议族路由 Provider: - OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 +- Gemini 原生协议:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - Anthropic 协议:Claude 原生 API 行为。 - Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。 -这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。 +这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。
智谱 (Zhipu) 配置示例 @@ -432,7 +474,7 @@ picoclaw agent -m "你好" { "agents": { "defaults": { - "model_name": "anthropic/claude-opus-4-5" + "model_name": "claude-opus-4-5" } }, "session": { diff --git a/docs/guides/routing-guide.md b/docs/guides/routing-guide.md new file mode 100644 index 000000000..a47984324 --- /dev/null +++ b/docs/guides/routing-guide.md @@ -0,0 +1,333 @@ +# Routing Guide + +> Back to [README](../README.md) + +In PicoClaw, routing has two user-facing parts: + +- **agent routing**: choose which agent should handle a message +- **model routing**: choose whether a turn should use the primary model or the configured light model + +This guide explains how to configure both for real deployments. + +## Quick Start + +### Route one Telegram group to a support agent + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + } + } + ] + } + } +} +``` + +### Route only Slack mentions in one workspace + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "slack mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +### Use a light model for simple turns + +```json +{ + "model_list": [ + { + "model_name": "gpt-main", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-main"] + }, + { + "model_name": "flash-light", + "provider": "gemini", + "model": "gemini-2.0-flash-exp", + "api_keys": ["sk-light"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-main", + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +## Agent Routing + +Agent routing is configured with: + +```text +agents.dispatch.rules +``` + +Rules are evaluated from top to bottom. +The **first matching rule wins**. +If no rule matches, PicoClaw falls back to the default agent. + +## Supported Match Fields + +| Field | Meaning | Example | +| --- | --- | --- | +| `channel` | Channel name | `telegram`, `slack`, `discord` | +| `account` | Normalized account ID | `default`, `bot2` | +| `space` | Workspace, guild, or similar container | `workspace:t001`, `guild:123456` | +| `chat` | Direct chat, group, or channel | `direct:user123`, `group:-100123`, `channel:c123` | +| `topic` | Thread or topic | `topic:42` | +| `sender` | Normalized sender identity | `12345`, `john` | +| `mentioned` | Whether the bot was explicitly mentioned | `true` | + +Values must match the normalized runtime shape, not the raw incoming payload. + +## Rule Ordering + +Put more specific rules before broader rules. + +Good: + +1. VIP sender inside one group +2. all traffic for that group +3. channel-wide fallback + +Bad: + +1. all traffic for that group +2. VIP sender inside the same group + +In the bad ordering, the broad rule wins first and the VIP rule never runs. + +## Session Interaction + +Routing and sessions are related but different. + +- routing decides which agent handles the message +- session settings decide which messages share memory + +You can override the global `session.dimensions` value for one matched rule with `session_dimensions`. + +Example: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +In this configuration: + +- the VIP gets routed to `sales` +- everyone else in the group goes to `support` +- the VIP route also gets per-user session isolation + +## Identity Links + +`session.identity_links` also affects routing when you match on `sender`. +Use it when the same real user may appear under multiple raw sender IDs. + +Example: + +```json +{ + "session": { + "identity_links": { + "john": ["slack:u123", "legacy-user-42"] + } + }, + "agents": { + "dispatch": { + "rules": [ + { + "name": "john goes to sales", + "agent": "sales", + "when": { + "sender": "john" + } + } + ] + } + } +} +``` + +## Model Routing + +Model routing is configured under: + +```text +agents.defaults.routing +``` + +Current fields: + +| Field | Meaning | +| --- | --- | +| `enabled` | Turn model routing on or off | +| `light_model` | `model_name` from `model_list` used for simple turns | +| `threshold` | Complexity cutoff in `[0, 1]` | + +Important behavior: + +- the light model must exist in `model_list` +- PicoClaw resolves the light model at startup; if it is invalid, routing is disabled +- one turn stays on one model tier, even if it later calls tools + +## What Affects The Complexity Score + +The current model router looks at structural signals such as: + +- message length +- fenced code blocks +- recent tool calls in the same session +- conversation depth +- media or attachments + +This means a "simple" turn may still go to the primary model if it includes: + +- code +- images or audio +- a very long prompt +- a tool-heavy ongoing workflow + +## Choosing A Threshold + +Recommended starting point: + +```json +{ + "agents": { + "defaults": { + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +General rule: + +- lower threshold: use the primary model more often +- higher threshold: use the light model more aggressively + +Practical suggestions: + +- `0.25` if you want safer routing with fewer light-model turns +- `0.35` as the default starting point +- `0.50+` only if your light model is already strong enough for most chat traffic + +## Troubleshooting + +### A rule is not matching + +Check: + +- rule order +- normalized value shape such as `group:-100123` instead of just `-100123` +- whether the channel actually provides `space`, `topic`, or `mentioned` + +### The wrong agent handles a message + +The most common cause is ordering. +Remember: first match wins. + +### The light model is never used + +Check: + +- `agents.defaults.routing.enabled` is `true` +- `light_model` exists in `model_list` +- the light model can actually initialize +- your threshold is not too low + +### The primary model is still chosen for short messages + +That can still happen when the turn includes: + +- a code block +- media or attachments +- recent tool-heavy history + +### Routing works, but the conversation memory is still too shared + +Adjust `session.dimensions` globally or `session_dimensions` on the specific route. +Routing chooses the agent, but sessions decide context sharing. + +## Related Guides + +- [Session Guide](session-guide.md) +- [Configuration Guide](configuration.md) +- [Providers & Model Configuration](providers.md) diff --git a/docs/guides/routing-guide.zh.md b/docs/guides/routing-guide.zh.md new file mode 100644 index 000000000..713cbeb04 --- /dev/null +++ b/docs/guides/routing-guide.zh.md @@ -0,0 +1,333 @@ +# 路由使用指南 + +> 返回 [README](../project/README.zh.md) + +PicoClaw 里用户能直接感知到的“路由”主要有两部分: + +- **agent 路由**:决定哪一个 agent 处理一条消息 +- **模型路由**:决定这一轮是走主模型,还是走轻量模型 + +这份文档面向真实部署中的配置使用场景。 + +## 快速开始 + +### 把一个 Telegram 群路由给 support agent + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "telegram support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + } + } + ] + } + } +} +``` + +### 只处理某个 Slack workspace 里的 @提及 + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "slack mentions", + "agent": "support", + "when": { + "channel": "slack", + "space": "workspace:t001", + "mentioned": true + } + } + ] + } + } +} +``` + +### 给简单请求启用轻量模型 + +```json +{ + "model_list": [ + { + "model_name": "gpt-main", + "provider": "openai", + "model": "gpt-5.4", + "api_keys": ["sk-main"] + }, + { + "model_name": "flash-light", + "provider": "gemini", + "model": "gemini-2.0-flash-exp", + "api_keys": ["sk-light"] + } + ], + "agents": { + "defaults": { + "model_name": "gpt-main", + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +## Agent 路由 + +Agent 路由通过下面这个配置项定义: + +```text +agents.dispatch.rules +``` + +规则从上到下依次检查。 +**第一条匹配的规则直接生效**。 +如果没有规则命中,PicoClaw 会回退到默认 agent。 + +## 支持的匹配字段 + +| 字段 | 含义 | 示例 | +| --- | --- | --- | +| `channel` | Channel 名称 | `telegram`、`slack`、`discord` | +| `account` | 归一化后的 account ID | `default`、`bot2` | +| `space` | workspace、guild 等上层容器 | `workspace:t001`、`guild:123456` | +| `chat` | 私聊、群或频道 | `direct:user123`、`group:-100123`、`channel:c123` | +| `topic` | 线程或话题 | `topic:42` | +| `sender` | 归一化后的发送者身份 | `12345`、`john` | +| `mentioned` | 是否显式 @ 了 bot | `true` | + +注意,配置里要写的是运行时归一化后的值,不是原始 webhook / SDK payload。 + +## 规则顺序 + +把更具体的规则放前面,把更宽泛的规则放后面。 + +正确顺序: + +1. 某个群里的 VIP 用户 +2. 这个群的全部消息 +3. 某个 channel 的更宽泛兜底 + +错误顺序: + +1. 这个群的全部消息 +2. 同一个群里的 VIP 用户 + +在错误顺序下,宽泛规则会先命中,VIP 规则永远不会生效。 + +## 和 Session 的关系 + +路由和 Session 是相关但不同的两件事: + +- 路由决定由哪个 agent 处理 +- Session 决定这些消息是否共享同一段记忆 + +如果你想让某条命中的路由使用不同的会话策略,可以用 `session_dimensions` 覆盖全局 `session.dimensions`。 + +示例: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" }, + { "id": "sales" } + ], + "dispatch": { + "rules": [ + { + "name": "vip in support group", + "agent": "sales", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890", + "sender": "12345" + }, + "session_dimensions": ["chat", "sender"] + }, + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个配置里: + +- VIP 用户会被路由到 `sales` +- 其他群成员会进入 `support` +- VIP 路由还会额外按 `chat + sender` 做每用户隔离 + +## Identity Links + +当你用 `sender` 做匹配时,`session.identity_links` 也会影响路由结果。 +适合这种场景:同一个真实用户可能出现为多个原始 sender ID。 + +示例: + +```json +{ + "session": { + "identity_links": { + "john": ["slack:u123", "legacy-user-42"] + } + }, + "agents": { + "dispatch": { + "rules": [ + { + "name": "john goes to sales", + "agent": "sales", + "when": { + "sender": "john" + } + } + ] + } + } +} +``` + +## 模型路由 + +模型路由配置在: + +```text +agents.defaults.routing +``` + +当前支持字段: + +| 字段 | 含义 | +| --- | --- | +| `enabled` | 开启或关闭模型路由 | +| `light_model` | `model_list` 中用于简单请求的 `model_name` | +| `threshold` | `[0, 1]` 范围内的复杂度阈值 | + +关键行为: + +- `light_model` 必须存在于 `model_list` +- PicoClaw 会在启动时解析轻量模型;如果模型无效,路由会被禁用 +- 同一轮 turn 只会使用同一档模型,不会中途切档 + +## 什么会影响复杂度分数 + +当前模型路由会看一些结构化信号,例如: + +- 消息长度 +- fenced code block +- 同一 session 最近是否频繁调用工具 +- 会话深度 +- 是否带有媒体或附件 + +因此,看起来“很简单”的消息,在以下情况下仍可能走主模型: + +- 带代码 +- 带图片或音频 +- prompt 很长 +- 当前是一个工具调用很多的工作流 + +## 阈值怎么选 + +推荐起点: + +```json +{ + "agents": { + "defaults": { + "routing": { + "enabled": true, + "light_model": "flash-light", + "threshold": 0.35 + } + } + } +} +``` + +通用规律: + +- 阈值越低,越容易回到主模型 +- 阈值越高,越积极地使用轻量模型 + +实用建议: + +- `0.25`:更保守,更少轻量模型 turn +- `0.35`:默认推荐起点 +- `0.50+`:只有当你的轻量模型已经能覆盖大多数聊天任务时再考虑 + +## 常见问题 + +### 某条规则没有命中 + +优先检查: + +- 规则顺序 +- 值的形状是否写成了归一化格式,例如 `group:-100123` 而不是裸 `-100123` +- 当前 channel 是否真的提供了 `space`、`topic` 或 `mentioned` + +### 消息被错误的 agent 处理了 + +最常见原因还是顺序。 +记住:第一条匹配的规则直接生效。 + +### 轻量模型从来没有被用到 + +检查: + +- `agents.defaults.routing.enabled` 是否为 `true` +- `light_model` 是否存在于 `model_list` +- 轻量模型能否成功初始化 +- 阈值是不是设得太低 + +### 明明是短消息,还是走了主模型 + +这通常是因为当前 turn 同时满足了其他“复杂”信号,例如: + +- 带代码块 +- 带媒体或附件 +- 最近的 session 历史里工具调用很多 + +### 路由没问题,但上下文还是共享得太多 + +去调整 `session.dimensions` 或某条 route 上的 `session_dimensions`。 +路由只决定“谁来处理”,session 才决定“记忆怎么共享”。 + +## 相关文档 + +- [Session 使用指南](session-guide.zh.md) +- [配置指南](configuration.zh.md) +- [Provider 与模型配置](providers.zh.md) diff --git a/docs/guides/session-guide.md b/docs/guides/session-guide.md new file mode 100644 index 000000000..3f3759260 --- /dev/null +++ b/docs/guides/session-guide.md @@ -0,0 +1,273 @@ +# Session Guide + +> Back to [README](../README.md) + +PicoClaw sessions decide which messages share the same conversation history. +If your bot "remembers too much" or "forgets too much", the first thing to check is the session configuration. + +This guide is for users configuring session behavior in `config.json`. +For implementation details, see the architecture docs instead. + +## What Sessions Control + +A session controls: + +- which previous messages are visible to the agent +- when summarization starts for that conversation +- whether two users in the same group share context +- whether different chats, threads, or spaces stay isolated + +Session data is stored under your workspace, typically: + +```text +~/.picoclaw/workspace/sessions/ +``` + +## Quick Start + +### Default: one context per chat + +This is the default and is the right choice for most bots. + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +Use this when: + +- each group/channel should have its own shared memory +- each direct message should have its own separate memory + +### Separate each user inside a group + +If users in the same group should not share memory, add `sender`: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +Use this when: + +- one shared assistant sits in a busy group +- each user should keep a private thread of context even inside the same room + +### Share one context across multiple rooms in the same workspace or guild + +If your channel exposes a `space` value, you can route by workspace or guild instead of by room: + +```json +{ + "session": { + "dimensions": ["space"] + } +} +``` + +Use this when: + +- a Slack workspace assistant should share context across channels +- a Discord guild assistant should share context across channels + +### Split by thread or forum topic + +If your channel exposes `topic`, you can isolate per thread: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +Use this when: + +- each forum topic should keep its own history +- each threaded discussion should stay separate + +## Available Dimensions + +| Dimension | What it means | Good for | +| --- | --- | --- | +| `space` | Workspace, guild, or similar top-level container | One shared assistant across many rooms | +| `chat` | Direct chat, group, or channel | Default per-room isolation | +| `topic` | Thread, topic, or forum sub-channel | Keep threaded discussions separate | +| `sender` | The message sender after normalization | Per-user context inside shared rooms | + +Not every channel provides every field. +If a channel does not supply `space` or `topic`, those dimensions simply have no effect for that message. + +## Important Behavior + +### Sessions are always separated by agent + +Even if two agents receive messages from the same chat, they do not share one session. + +### Sessions are still separated by channel and account + +`session.dimensions` adds finer-grained isolation, but PicoClaw still keeps a baseline separation by: + +- agent +- channel +- account + +That means an empty or very small `dimensions` list does **not** create one global memory across every platform. + +### Telegram forum topics already stay isolated in the default `chat` mode + +Telegram forum messages keep topic isolation by default even when `dimensions` only contains `chat`. +You usually do not need a special workaround for Telegram forums. + +### Summaries happen per session + +`summarize_message_threshold` and `summarize_token_percent` apply inside each session independently. +If you create smaller sessions, summarization also happens on smaller per-session histories. + +## Common Recipes + +### One shared assistant per group or direct chat + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +### One context per user inside each chat + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### One context per sender across one workspace or guild + +```json +{ + "session": { + "dimensions": ["space", "sender"] + } +} +``` + +This is useful for workspace-wide assistants where each user should keep their own memory while moving across rooms in the same workspace. + +### Use a different session policy for one routed agent only + +You can keep the global default and override it for one dispatch rule: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat", "sender"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +In this example: + +- most traffic uses one shared context per chat +- the support group uses one context per user inside that chat + +## Identity Links + +`session.identity_links` helps when the same user may appear under multiple raw sender IDs and you want PicoClaw to treat them as one sender identity. + +Example: + +```json +{ + "session": { + "dimensions": ["chat", "sender"], + "identity_links": { + "john": ["slack:u123", "u123", "legacy-user-42"] + } + } +} +``` + +This is mainly useful for: + +- migrated sender IDs +- platform-specific ID aliases +- cleanup after changing channel adapters or account naming + +Current limitation: + +- `identity_links` does not make one user share memory across different channels automatically +- channel and account remain part of the baseline session scope + +## Troubleshooting + +### Users in one group are sharing memory + +Your current session is probably keyed only by `chat`. +Switch to: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### The same user does not share memory across Slack and Telegram + +That is expected. +PicoClaw still separates sessions by channel even if you use `sender`. + +### Threads are mixing together + +Add `topic` when the channel provides one: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +### Old sessions seem to use legacy keys + +That is normal during migration. +PicoClaw keeps compatibility with older `agent:...` session keys while moving runtime storage to opaque canonical keys. + +## Related Guides + +- [Configuration Guide](configuration.md) +- [Routing Guide](routing-guide.md) +- [Providers & Model Configuration](providers.md) diff --git a/docs/guides/session-guide.zh.md b/docs/guides/session-guide.zh.md new file mode 100644 index 000000000..679a7f68d --- /dev/null +++ b/docs/guides/session-guide.zh.md @@ -0,0 +1,273 @@ +# Session 使用指南 + +> 返回 [README](../project/README.zh.md) + +PicoClaw 的 Session 决定了哪些消息会共享同一段对话历史。 +如果你的 bot 表现为“记得太多”或“忘得太快”,首先就该检查 session 配置。 + +这份文档面向编辑 `config.json` 的普通用户。 +如果你想看内部实现细节,请看 architecture 文档,而不是这里。 + +## Session 控制什么 + +一个 session 会影响: + +- Agent 能看到哪些历史消息 +- 这段对话何时开始触发摘要 +- 同一个群里的不同用户是否共享上下文 +- 不同聊天、不同线程、不同空间是否保持隔离 + +Session 数据保存在工作区目录下,通常是: + +```text +~/.picoclaw/workspace/sessions/ +``` + +## 快速开始 + +### 默认:每个 chat 一段上下文 + +这是默认值,也是大多数 bot 的正确起点。 + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +适用场景: + +- 每个群 / 频道都有自己的共享记忆 +- 每个私聊都有各自独立的记忆 + +### 在同一个群里按用户分开 + +如果同一个群里的不同用户不应该共享上下文,增加 `sender`: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +适用场景: + +- 一个群里挂着一个共享 assistant,但不希望用户之间串上下文 +- 希望每个用户在同一个房间里保留自己的独立记忆 + +### 在同一个 workspace / guild 下跨多个房间共享上下文 + +如果你的 channel 会提供 `space`,可以按 workspace 或 guild 共享,而不是按单个房间共享: + +```json +{ + "session": { + "dimensions": ["space"] + } +} +``` + +适用场景: + +- Slack workspace 里的 assistant 想跨多个 channel 共享上下文 +- Discord guild 里的 assistant 想跨多个 channel 共享上下文 + +### 按线程或论坛 topic 隔离 + +如果 channel 会提供 `topic`,可以显式按线程隔离: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +适用场景: + +- 每个论坛 topic 都要保留独立历史 +- 每个 threaded discussion 都不能串上下文 + +## 可用维度 + +| 维度 | 含义 | 适合什么场景 | +| --- | --- | --- | +| `space` | workspace、guild 或类似的上层容器 | 一个 assistant 跨多个房间共享上下文 | +| `chat` | 私聊、群聊或频道 | 默认按房间隔离 | +| `topic` | 线程、topic 或 forum 子通道 | 让 threaded discussion 保持隔离 | +| `sender` | 归一化后的消息发送者 | 在共享房间内按用户隔离 | + +并不是每个 channel 都会提供全部字段。 +如果某个 channel 没有 `space` 或 `topic`,对应维度对那条消息就不会生效。 + +## 关键行为 + +### Session 总是按 agent 分开 + +即使两个 agent 处理同一个 chat,它们也不会共享同一段 session。 + +### Session 仍然会按 channel 和 account 分开 + +`session.dimensions` 只是添加更细的隔离维度,PicoClaw 仍然保留一层基础隔离: + +- agent +- channel +- account + +这意味着即使 `dimensions` 为空,系统也**不会**把所有平台的消息都混成一个全局记忆。 + +### Telegram forum topic 在默认 `chat` 模式下也会保持隔离 + +Telegram forum 消息在默认 `chat` 模式下就会保留 topic 隔离。 +通常不需要额外为 Telegram forum 单独写 workaround。 + +### 摘要是按 session 触发的 + +`summarize_message_threshold` 和 `summarize_token_percent` 都是针对单个 session 生效。 +如果你把 session 切得更小,摘要也会按更小的历史范围触发。 + +## 常见配置方案 + +### 每个群 / 私聊共享一段上下文 + +```json +{ + "session": { + "dimensions": ["chat"] + } +} +``` + +### 每个 chat 内再按用户拆分 + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### 在同一个 workspace / guild 内按用户保留上下文 + +```json +{ + "session": { + "dimensions": ["space", "sender"] + } +} +``` + +这适合做 workspace 级 assistant:用户在同一个 workspace 里跨多个房间移动,但仍保留自己的上下文。 + +### 只给某个路由出来的 agent 覆盖 session 策略 + +你可以保留全局默认值,再在某条 dispatch rule 上单独覆盖: + +```json +{ + "agents": { + "list": [ + { "id": "main", "default": true }, + { "id": "support" } + ], + "dispatch": { + "rules": [ + { + "name": "support group", + "agent": "support", + "when": { + "channel": "telegram", + "chat": "group:-1001234567890" + }, + "session_dimensions": ["chat", "sender"] + } + ] + } + }, + "session": { + "dimensions": ["chat"] + } +} +``` + +在这个例子里: + +- 大部分流量仍然按 `chat` 共享上下文 +- 只有 support 群按 `chat + sender` 拆成每人一段上下文 + +## Identity Links + +`session.identity_links` 适合处理这种场景:同一个人可能会以多个原始 sender ID 出现,但你希望 PicoClaw 把它们视为同一个发送者身份。 + +示例: + +```json +{ + "session": { + "dimensions": ["chat", "sender"], + "identity_links": { + "john": ["slack:u123", "u123", "legacy-user-42"] + } + } +} +``` + +这主要适用于: + +- sender ID 迁移 +- 同一平台下的多个 ID 别名 +- 调整 channel adapter 或 account 命名后的兼容清理 + +当前限制: + +- `identity_links` 不会自动让同一个用户跨不同 channel 共享记忆 +- channel 和 account 仍然属于基础 session scope 的一部分 + +## 常见问题 + +### 同一个群里的用户在共享记忆 + +大概率是当前 session 只按 `chat` 建。 +改成: + +```json +{ + "session": { + "dimensions": ["chat", "sender"] + } +} +``` + +### 同一个用户在 Slack 和 Telegram 之间没有共享记忆 + +这是当前实现下的预期行为。 +即使使用了 `sender`,PicoClaw 仍然会按 channel 做基础隔离。 + +### 不同线程混在一起了 + +如果这个 channel 提供 `topic`,加上它: + +```json +{ + "session": { + "dimensions": ["chat", "topic"] + } +} +``` + +### 升级后看到旧的 session key + +这属于正常兼容行为。 +PicoClaw 在迁移到新的 opaque canonical key 时,仍会兼容旧的 `agent:...` session key。 + +## 相关文档 + +- [配置指南](configuration.zh.md) +- [路由指南](routing-guide.zh.md) +- [Provider 与模型配置](providers.zh.md) diff --git a/docs/fr/spawn-tasks.md b/docs/guides/spawn-tasks.fr.md similarity index 97% rename from docs/fr/spawn-tasks.md rename to docs/guides/spawn-tasks.fr.md index 5635cd645..40a7a3ded 100644 --- a/docs/fr/spawn-tasks.md +++ b/docs/guides/spawn-tasks.fr.md @@ -1,6 +1,6 @@ # 🔄 Tâches Asynchrones et Spawn -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## Tâches Rapides (réponse directe) diff --git a/docs/ja/spawn-tasks.md b/docs/guides/spawn-tasks.ja.md similarity index 98% rename from docs/ja/spawn-tasks.md rename to docs/guides/spawn-tasks.ja.md index a13aab9eb..598654242 100644 --- a/docs/ja/spawn-tasks.md +++ b/docs/guides/spawn-tasks.ja.md @@ -1,6 +1,6 @@ # 🔄 非同期タスクと Spawn -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ### Spawn を使用した非同期タスク diff --git a/docs/spawn-tasks.md b/docs/guides/spawn-tasks.md similarity index 100% rename from docs/spawn-tasks.md rename to docs/guides/spawn-tasks.md diff --git a/docs/my/spawn-tasks.md b/docs/guides/spawn-tasks.ms.md similarity index 97% rename from docs/my/spawn-tasks.md rename to docs/guides/spawn-tasks.ms.md index c0c3e8f92..055ebf20d 100644 --- a/docs/my/spawn-tasks.md +++ b/docs/guides/spawn-tasks.ms.md @@ -1,6 +1,6 @@ # 🔄 Spawn & Tugasan Async -> Kembali ke [README](../../README.my.md) +> Kembali ke [README](../project/README.ms.md) ## Tugasan Cepat (balas terus) diff --git a/docs/pt-br/spawn-tasks.md b/docs/guides/spawn-tasks.pt-br.md similarity index 97% rename from docs/pt-br/spawn-tasks.md rename to docs/guides/spawn-tasks.pt-br.md index d6b539cb1..0de929821 100644 --- a/docs/pt-br/spawn-tasks.md +++ b/docs/guides/spawn-tasks.pt-br.md @@ -1,6 +1,6 @@ # 🔄 Tarefas Assíncronas e Spawn -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## Tarefas Rápidas (resposta direta) diff --git a/docs/vi/spawn-tasks.md b/docs/guides/spawn-tasks.vi.md similarity index 97% rename from docs/vi/spawn-tasks.md rename to docs/guides/spawn-tasks.vi.md index 78f728040..e8533750b 100644 --- a/docs/vi/spawn-tasks.md +++ b/docs/guides/spawn-tasks.vi.md @@ -1,6 +1,6 @@ # 🔄 Tác Vụ Bất Đồng Bộ và Spawn -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## Tác Vụ Nhanh (phản hồi trực tiếp) diff --git a/docs/zh/spawn-tasks.md b/docs/guides/spawn-tasks.zh.md similarity index 98% rename from docs/zh/spawn-tasks.md rename to docs/guides/spawn-tasks.zh.md index 781462af2..ee5f1580e 100644 --- a/docs/zh/spawn-tasks.md +++ b/docs/guides/spawn-tasks.zh.md @@ -1,6 +1,6 @@ # 🔄 异步任务与 Spawn -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 通过 `spawn` 工具支持**异步任务执行**。主要由 **Heartbeat(心跳)** 系统使用,在不阻塞主 Agent 循环的情况下运行耗时任务。 diff --git a/docs/migration/README.md b/docs/migration/README.md new file mode 100644 index 000000000..eb37eec20 --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,5 @@ +# Migration + +Migration notes for major configuration and behavior changes across PicoClaw versions. + +- [Migration Guide: From `providers` to `model_list`](model-list-migration.md): update legacy provider config to the current `model_list` format. diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index 15d531cf7..4fb37c580 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -8,7 +8,7 @@ The new `model_list` configuration offers several advantages: - **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only - **Load balancing**: Configure multiple endpoints for the same model -- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc. +- **Explicit provider resolution**: Prefer `provider` + native `model`, with legacy `provider/model` compatibility when needed - **Cleaner configuration**: Model-centric instead of vendor-centric ## Timeline @@ -54,18 +54,21 @@ The new `model_list` configuration offers several advantages: "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-your-openai-key"], "api_base": "https://api.openai.com/v1" }, { "model_name": "claude-sonnet-4.6", - "model": "anthropic/claude-sonnet-4.6", + "provider": "anthropic", + "model": "claude-sonnet-4.6", "api_keys": ["sk-ant-your-key"] }, { "model_name": "deepseek", - "model": "deepseek/deepseek-chat", + "provider": "deepseek", + "model": "deepseek-chat", "api_keys": ["sk-your-deepseek-key"] } ], @@ -79,40 +82,46 @@ The new `model_list` configuration offers several advantages: > **Note**: The `enabled` field can be omitted — during V1→V2 migration it is auto-inferred (models with API keys or the `local-model` name are enabled by default). For new configs, you can explicitly set `"enabled": false` to disable a model entry without removing it. -## Protocol Prefixes +## Provider / Model Resolution -The `model` field uses a protocol prefix format: `[protocol/]model-identifier` +Preferred format: -| Prefix | Description | Example | -|--------|-------------|---------| -| `openai/` | OpenAI API (default) | `openai/gpt-5.4` | -| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | -| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | -| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` | -| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` | -| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` | -| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` | -| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` | -| `groq/` | Groq API | `groq/llama-3.1-70b` | -| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` | -| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` | -| `qwen/` | Alibaba Qwen | `qwen/qwen-max` | -| `zhipu/` | Zhipu AI | `zhipu/glm-4` | -| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` | -| `ollama/` | Ollama (local) | `ollama/llama3` | -| `vllm/` | vLLM (local) | `vllm/my-model` | -| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` | -| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` | -| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` | +```json +{ + "provider": "openai", + "model": "gpt-5.4" +} +``` -**Note**: If no prefix is specified, `openai/` is used as the default. +Legacy compatibility format: + +```json +{ + "model": "openai/gpt-5.4" +} +``` + +Resolution rules: + +1. If `provider` is set, PicoClaw sends `model` unchanged. +2. If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID. + +Examples: + +| Config | Resolved Provider | Model Sent Upstream | +|--------|-------------------|---------------------| +| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` | +| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` | +| `"provider": "openrouter", "model": "google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` | +| `"model": "openrouter/google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` | ## ModelConfig Fields | Field | Required | Description | |-------|----------|-------------| | `model_name` | Yes | User-facing alias for the model | -| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) | +| `provider` | No | Preferred provider identifier. When set, `model` is sent unchanged | +| `model` | Yes | Native model ID when `provider` is set, or legacy `provider/model` when `provider` is omitted | | `api_base` | No | API endpoint URL | | `api_keys` | No | API authentication keys (array; supports multiple keys for load balancing) | | `enabled` | No | Whether this model entry is active. Defaults to `true` during migration for models with API keys or named `local-model`. Set to `false` to disable. | @@ -136,7 +145,8 @@ There are two ways to configure load balancing: "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-key1", "sk-key2", "sk-key3"], "api_base": "https://api.openai.com/v1" } @@ -162,19 +172,22 @@ model_list: "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-key1"], "api_base": "https://api1.example.com/v1" }, { "model_name": "gpt4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-key2"], "api_base": "https://api2.example.com/v1" }, { "model_name": "gpt4", - "model": "openai/gpt-5.4", + "provider": "openai", + "model": "gpt-5.4", "api_keys": ["sk-key3"], "api_base": "https://api3.example.com/v1" } @@ -193,7 +206,8 @@ With `model_list`, adding a new provider requires zero code changes: "model_list": [ { "model_name": "my-custom-llm", - "model": "openai/my-model-v1", + "provider": "openai", + "model": "my-model-v1", "api_keys": ["your-api-key"], "api_base": "https://api.your-provider.com/v1" } @@ -201,7 +215,7 @@ With `model_list`, adding a new provider requires zero code changes: } ``` -Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL. +Just set `provider` to `openai` (or another supported provider), and provide your provider's API base URL. ## Backward Compatibility @@ -216,7 +230,7 @@ During the migration period, your existing V0/V1 config will be auto-migrated to - [ ] Identify all providers you're currently using - [ ] Create `model_list` entries for each provider -- [ ] Use appropriate protocol prefixes +- [ ] Prefer explicit `provider` values and native model IDs - [ ] Update `agents.defaults.model_name` to reference the new `model_name` - [ ] Test that all models work correctly - [ ] Remove or comment out the old `providers` section @@ -234,10 +248,10 @@ model "xxx" not found in model_list or providers ### Unknown protocol error ``` -unknown protocol "xxx" in model "xxx/model-name" +unknown provider "xxx" in model "xxx/model-name" ``` -**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above. +**Solution**: Use a supported `provider` value, or use the legacy `provider/model` compatibility form correctly. See [Provider / Model Resolution](#provider--model-resolution). ### Missing API key error diff --git a/docs/operations/README.md b/docs/operations/README.md new file mode 100644 index 000000000..b775ca3d9 --- /dev/null +++ b/docs/operations/README.md @@ -0,0 +1,6 @@ +# Operations + +Operational docs for debugging, diagnosis, and production troubleshooting. + +- [Troubleshooting](troubleshooting.md): common failures, symptoms, and recovery steps. +- [Debugging PicoClaw](debug.md): logs, runtime visibility, and debugging workflow. diff --git a/docs/fr/debug.md b/docs/operations/debug.fr.md similarity index 97% rename from docs/fr/debug.md rename to docs/operations/debug.fr.md index 5753ccf8c..331f7c4ba 100644 --- a/docs/fr/debug.md +++ b/docs/operations/debug.fr.md @@ -1,6 +1,6 @@ # Débogage de PicoClaw -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) PicoClaw effectue de multiples interactions complexes en arrière-plan pour chaque requête qu'il reçoit — du routage des messages et de l'évaluation de la complexité, à l'exécution des outils et à l'adaptation aux défaillances de modèle. Pouvoir voir exactement ce qui se passe est crucial, non seulement pour résoudre les problèmes potentiels, mais aussi pour véritablement comprendre le fonctionnement de l'agent. diff --git a/docs/ja/debug.md b/docs/operations/debug.ja.md similarity index 97% rename from docs/ja/debug.md rename to docs/operations/debug.ja.md index ecc52f454..5b3365bf8 100644 --- a/docs/ja/debug.md +++ b/docs/operations/debug.ja.md @@ -1,6 +1,6 @@ # PicoClaw のデバッグ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る PicoClaw は、受信するすべてのリクエストに対して、メッセージのルーティングや複雑度の評価、ツールの実行、モデル障害への適応など、多くの複雑な処理をバックグラウンドで実行しています。何が起きているかを正確に把握できることは、潜在的な問題のトラブルシューティングだけでなく、エージェントの動作を真に理解するためにも非常に重要です。 diff --git a/docs/debug.md b/docs/operations/debug.md similarity index 95% rename from docs/debug.md rename to docs/operations/debug.md index b9e776f0f..eacd72380 100644 --- a/docs/debug.md +++ b/docs/operations/debug.md @@ -65,7 +65,8 @@ Debug logs are server-side only. If you want the agent to send a visible notific "defaults": { "tool_feedback": { "enabled": true, - "max_args_length": 300 + "max_args_length": 300, + "separate_messages": true } } } @@ -85,6 +86,7 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo | Field | Type | Default | Description | |---|---|---|---| | `enabled` | bool | `false` | Send a chat notification for each tool call | +| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message | | `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification | ### Environment variables diff --git a/docs/my/debug.md b/docs/operations/debug.ms.md similarity index 100% rename from docs/my/debug.md rename to docs/operations/debug.ms.md diff --git a/docs/pt-br/debug.md b/docs/operations/debug.pt-br.md similarity index 97% rename from docs/pt-br/debug.md rename to docs/operations/debug.pt-br.md index 8614cd5ed..655385840 100644 --- a/docs/pt-br/debug.md +++ b/docs/operations/debug.pt-br.md @@ -1,6 +1,6 @@ # Depuração do PicoClaw -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) O PicoClaw realiza múltiplas interações complexas nos bastidores para cada requisição que recebe — desde o roteamento de mensagens e avaliação de complexidade, até a execução de ferramentas e adaptação a falhas de modelo. Poder ver exatamente o que está acontecendo é crucial, não apenas para solucionar problemas potenciais, mas também para realmente entender como o agente opera. diff --git a/docs/vi/debug.md b/docs/operations/debug.vi.md similarity index 97% rename from docs/vi/debug.md rename to docs/operations/debug.vi.md index 69583d486..76d555648 100644 --- a/docs/vi/debug.md +++ b/docs/operations/debug.vi.md @@ -1,6 +1,6 @@ # Gỡ lỗi PicoClaw -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) PicoClaw thực hiện nhiều tương tác phức tạp ở hậu trường cho mỗi yêu cầu nhận được — từ định tuyến tin nhắn và đánh giá độ phức tạp, đến thực thi công cụ và thích ứng với lỗi mô hình. Khả năng xem chính xác những gì đang xảy ra là rất quan trọng, không chỉ để khắc phục các sự cố tiềm ẩn, mà còn để thực sự hiểu cách agent hoạt động. diff --git a/docs/zh/debug.md b/docs/operations/debug.zh.md similarity index 97% rename from docs/zh/debug.md rename to docs/operations/debug.zh.md index e7f20d777..8e544c03b 100644 --- a/docs/zh/debug.md +++ b/docs/operations/debug.zh.md @@ -1,6 +1,6 @@ # 调试 PicoClaw -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 在处理每一个请求时,都会在后台执行多个复杂的交互操作——从消息路由和复杂度评估,到工具执行和模型故障适配。能够准确地看到正在发生什么至关重要,这不仅有助于排查潜在问题,也有助于真正理解代理的运作方式。 diff --git a/docs/fr/troubleshooting.md b/docs/operations/troubleshooting.fr.md similarity index 97% rename from docs/fr/troubleshooting.md rename to docs/operations/troubleshooting.fr.md index d2d099ad3..630f69627 100644 --- a/docs/fr/troubleshooting.md +++ b/docs/operations/troubleshooting.fr.md @@ -1,6 +1,6 @@ # 🐛 Dépannage -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" diff --git a/docs/ja/troubleshooting.md b/docs/operations/troubleshooting.ja.md similarity index 97% rename from docs/ja/troubleshooting.md rename to docs/operations/troubleshooting.ja.md index f18b456db..f1d244c92 100644 --- a/docs/ja/troubleshooting.md +++ b/docs/operations/troubleshooting.ja.md @@ -1,6 +1,6 @@ # 🐛 トラブルシューティング -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る ## "model ... not found in model_list" または OpenRouter "free is not a valid model ID" diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md new file mode 100644 index 000000000..16229f369 --- /dev/null +++ b/docs/operations/troubleshooting.md @@ -0,0 +1,50 @@ +# Troubleshooting + +## "model ... not found in model_list" or OpenRouter "free is not a valid model ID" + +**Symptom:** You see either: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter returns 400: `"free is not a valid model ID"` + +**Cause:** PicoClaw now resolves provider/model in two steps: + +- If `provider` is set, the `model` field is sent to that provider unchanged. +- If `provider` is omitted, PicoClaw infers the provider from the first `/` segment and sends everything after that first `/` as the runtime model ID. + +For OpenRouter free-tier routing, the preferred config is explicit `provider`. + +- **Wrong:** `"model": "free"` → no OpenRouter provider is selected, so `free` is not a valid OpenRouter model route. +- **Right:** `"provider": "openrouter", "model": "free"` → OpenRouter receives `free`. +- **Also supported:** `"model": "openrouter/free"` → provider resolves to `openrouter`, runtime model ID resolves to `free`. + +**Fix:** In `~/.picoclaw/config.json` (or your config path): + +1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). +2. That entry should preferably set **provider** to `openrouter`, and **model** should be a valid OpenRouter model ID, for example: + - `"free"` – auto free-tier + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Example snippet: + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "provider": "openrouter", + "model": "free", + "api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"], + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Get your key at [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/my/troubleshooting.md b/docs/operations/troubleshooting.ms.md similarity index 100% rename from docs/my/troubleshooting.md rename to docs/operations/troubleshooting.ms.md diff --git a/docs/pt-br/troubleshooting.md b/docs/operations/troubleshooting.pt-br.md similarity index 96% rename from docs/pt-br/troubleshooting.md rename to docs/operations/troubleshooting.pt-br.md index 286ad2ac8..eec64d9d8 100644 --- a/docs/pt-br/troubleshooting.md +++ b/docs/operations/troubleshooting.pt-br.md @@ -1,6 +1,6 @@ # 🐛 Solução de Problemas -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) ## "model ... not found in model_list" ou OpenRouter "free is not a valid model ID" diff --git a/docs/vi/troubleshooting.md b/docs/operations/troubleshooting.vi.md similarity index 97% rename from docs/vi/troubleshooting.md rename to docs/operations/troubleshooting.vi.md index 961c932aa..8aa5e2ae4 100644 --- a/docs/vi/troubleshooting.md +++ b/docs/operations/troubleshooting.vi.md @@ -1,6 +1,6 @@ # 🐛 Khắc Phục Sự Cố -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) ## "model ... not found in model_list" hoặc OpenRouter "free is not a valid model ID" diff --git a/docs/operations/troubleshooting.zh.md b/docs/operations/troubleshooting.zh.md new file mode 100644 index 000000000..1569e3385 --- /dev/null +++ b/docs/operations/troubleshooting.zh.md @@ -0,0 +1,52 @@ +# 🐛 疑难解答 + +> 返回 [README](../project/README.zh.md) + +## "model ... not found in model_list" 或 OpenRouter "free is not a valid model ID" + +**症状:** 你看到以下任一错误: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter 返回 400:`"free is not a valid model ID"` + +**原因:** PicoClaw 现在按两步解析 provider 和 model: + +- 如果设置了 `provider`,则会把 `model` 原样发送给该 provider。 +- 如果未设置 `provider`,则会把 `model` 第一个 `/` 之前的字段当作 provider,并把第一个 `/` 之后的全部内容当作最终发送的模型 ID。 + +对于 OpenRouter 免费层路由,推荐显式设置 `provider`。 + +- **错误:** `"model": "free"` → 不会选中 OpenRouter,`free` 也不是可直接路由的 OpenRouter 模型配置。 +- **正确:** `"provider": "openrouter", "model": "free"` → OpenRouter 收到 `free`。 +- **也兼容:** `"model": "openrouter/free"` → provider 解析为 `openrouter`,最终模型 ID 解析为 `free`。 + +**修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中: + +1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。 +2. 该条目推荐显式设置 **provider** 为 `openrouter`,并在 **model** 中填写有效的 OpenRouter 模型 ID,例如: + - `"free"` – 自动免费层 + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +示例片段: + +```json +{ + "agents": { + "defaults": { + "model_name": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "provider": "openrouter", + "model": "free", + "api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"], + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +在 [OpenRouter Keys](https://openrouter.ai/keys) 获取你的密钥。 diff --git a/CONTRIBUTING.zh.md b/docs/project/CONTRIBUTING.zh.md similarity index 100% rename from CONTRIBUTING.zh.md rename to docs/project/CONTRIBUTING.zh.md diff --git a/README.fr.md b/docs/project/README.fr.md similarity index 82% rename from README.fr.md rename to docs/project/README.fr.md index 8fa67fa02..1e2f59bee 100644 --- a/README.fr.md +++ b/docs/project/README.fr.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

PicoClaw : Assistant IA Ultra-Efficace en Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **Français** | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -35,12 +35,12 @@

- +

- +

@@ -72,7 +72,7 @@ 2026-02-26 🎉 PicoClaw atteint **20K Stars** en seulement 17 jours ! L'orchestration automatique des channels et les interfaces de capacités sont disponibles. -2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](ROADMAP.md) officiellement lancés. +2026-02-16 🎉 PicoClaw dépasse 12K Stars en une semaine ! Rôles de mainteneurs communautaires et [Roadmap](../../ROADMAP.md) officiellement lancés. 2026-02-13 🎉 PicoClaw dépasse 5000 Stars en 4 jours ! Roadmap du projet et groupes de développeurs en cours. @@ -110,14 +110,14 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de | **Temps de démarrage**
(cœur 0,8 GHz) | >500s | >30s | **<1s** | | **Coût** | Mac Mini $599 | La plupart des cartes Linux ~$50 | **N'importe quelle carte Linux**
**à partir de $10** | -PicoClaw +PicoClaw -> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR ! +> **[Liste de compatibilité matérielle](../guides/hardware-compatibility.fr.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Démonstration @@ -131,9 +131,9 @@ _*Les builds récents peuvent utiliser 10-20 Mo en raison des fusions rapides de

Recherche Web & Apprentissage

-

-

-

+

+

+

Développer · Déployer · Mettre à l'échelle @@ -223,7 +223,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Pour commencer :** @@ -277,7 +277,7 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha **Étape 1 :** Double-cliquez sur `picoclaw-launcher`. Un avertissement de sécurité s'affiche :

-Avertissement macOS Gatekeeper +Avertissement macOS Gatekeeper

> *"picoclaw-launcher" n'a pas pu être ouvert — Apple n'a pas pu vérifier que "picoclaw-launcher" ne contient pas de logiciel malveillant susceptible de nuire à votre Mac ou de compromettre votre confidentialité.* @@ -285,7 +285,7 @@ macOS peut bloquer `picoclaw-launcher` au premier lancement car il est télécha **Étape 2 :** Ouvrez **Réglages Système** → **Confidentialité et sécurité** → faites défiler jusqu'à la section **Sécurité** → cliquez sur **Ouvrir quand même** → confirmez en cliquant sur **Ouvrir quand même** dans la boîte de dialogue.

-macOS Confidentialité et sécurité — Ouvrir quand même +macOS Confidentialité et sécurité — Ouvrir quand même

Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des lancements suivants. @@ -301,7 +301,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Pour commencer :** @@ -310,6 +310,7 @@ Utilisez les menus TUI pour : **1)** Configurer un Provider -> **2)** Configurer Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. @@ -320,10 +321,10 @@ Aperçu : - - - - + + + +
@@ -347,7 +348,7 @@ termux-chroot ./picoclaw onboard # chroot fournit une arborescence Linux stand Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configuration. -PicoClaw on Termux +PicoClaw on Termux Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON. @@ -454,7 +455,7 @@ PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Ut } ``` -Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md). +Pour les détails complets de configuration des providers, voir [Providers & Models](../guides/providers.fr.md).
@@ -464,28 +465,28 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie : | Channel | Configuration | Protocole | Docs | |---------|---------------|-----------|------| -| **Telegram** | Facile (token bot) | Long polling | [Guide](docs/channels/telegram/README.fr.md) | -| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](docs/channels/discord/README.fr.md) | -| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](docs/fr/chat-apps.md#whatsapp) | -| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](docs/fr/chat-apps.md#weixin) | -| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.fr.md) | -| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](docs/channels/slack/README.fr.md) | -| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.fr.md) | -| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](docs/channels/dingtalk/README.fr.md) | -| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.fr.md) | -| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](docs/channels/line/README.fr.md) | -| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](docs/channels/wecom/README.md) | -| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](docs/fr/chat-apps.md#irc) | -| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](docs/channels/onebot/README.fr.md) | -| **MaixCam** | Facile (activer) | Socket TCP | [Guide](docs/channels/maixcam/README.fr.md) | +| **Telegram** | Facile (token bot) | Long polling | [Guide](../channels/telegram/README.fr.md) | +| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](../channels/discord/README.fr.md) | +| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](../guides/chat-apps.fr.md#whatsapp) | +| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](../guides/chat-apps.fr.md#weixin) | +| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](../channels/qq/README.fr.md) | +| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](../channels/slack/README.fr.md) | +| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](../channels/matrix/README.fr.md) | +| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](../channels/dingtalk/README.fr.md) | +| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](../channels/feishu/README.fr.md) | +| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](../channels/line/README.fr.md) | +| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](../channels/wecom/README.fr.md) | +| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](../guides/chat-apps.fr.md#irc) | +| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](../channels/onebot/README.fr.md) | +| **MaixCam** | Facile (activer) | Socket TCP | [Guide](../channels/maixcam/README.fr.md) | | **Pico** | Facile (activer) | Protocole natif | Intégré | | **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré | > Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé. -> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails. +> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](../guides/configuration.fr.md#niveau-de-log-du-gateway) pour plus de détails. -Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md). +Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](../guides/chat-apps.fr.md). ## 🔧 Outils @@ -505,7 +506,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations ### ⚙️ Autres outils -PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](docs/fr/tools_configuration.md) pour les détails. +PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](../reference/tools_configuration.fr.md) pour les détails. ## 🎯 Skills @@ -535,7 +536,7 @@ Ajoutez à votre `config.json` : } ``` -Pour plus de détails, voir [Configuration des outils - Skills](docs/fr/tools_configuration.md#skills-tool). +Pour plus de détails, voir [Configuration des outils - Skills](../reference/tools_configuration.fr.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -558,9 +559,9 @@ PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connect } ``` -Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](docs/fr/tools_configuration.md#mcp-tool). +Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](../reference/tools_configuration.fr.md#mcp-tool). -## ClawdChat Rejoignez le réseau social des Agents +## ClawdChat Rejoignez le réseau social des Agents Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée. @@ -601,23 +602,23 @@ Pour des guides détaillés au-delà de ce README : | Sujet | Description | |-------|-------------| -| [Docker & Démarrage rapide](docs/fr/docker.md) | Configuration Docker Compose, modes Launcher/Agent | -| [Applications de chat](docs/fr/chat-apps.md) | Guides de configuration pour les 17+ channels | -| [Configuration](docs/fr/configuration.md) | Variables d'environnement, structure du workspace, sandbox de sécurité | -| [Providers & Modèles](docs/fr/providers.md) | 30+ providers LLM, routage de modèles, configuration model_list | -| [Spawn & Tâches asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones | -| [Hooks](docs/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation | -| [Steering](docs/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | -| [SubTurn](docs/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | -| [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions | -| [Configuration des outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills | -| [Compatibilité matérielle](docs/fr/hardware-compatibility.md) | Cartes testées, exigences minimales | +| [Docker & Démarrage rapide](../guides/docker.fr.md) | Configuration Docker Compose, modes Launcher/Agent | +| [Applications de chat](../guides/chat-apps.fr.md) | Guides de configuration pour les 17+ channels | +| [Configuration](../guides/configuration.fr.md) | Variables d'environnement, structure du workspace, sandbox de sécurité | +| [Providers & Modèles](../guides/providers.fr.md) | 30+ providers LLM, routage de modèles, configuration model_list | +| [Spawn & Tâches asynchrones](../guides/spawn-tasks.fr.md) | Tâches rapides, tâches longues avec spawn, orchestration de sous-agents asynchrones | +| [Hooks](../architecture/hooks/README.md) | Système de hooks événementiels : observateurs, intercepteurs, hooks d'approbation | +| [Steering](../architecture/steering.md) | Injecter des messages dans une boucle agent en cours d'exécution | +| [SubTurn](../architecture/subturn.md) | Coordination de subagents, contrôle de concurrence, cycle de vie | +| [Dépannage](../operations/troubleshooting.fr.md) | Problèmes courants et solutions | +| [Configuration des outils](../reference/tools_configuration.fr.md) | Activation/désactivation par outil, politiques d'exécution, MCP, Skills | +| [Compatibilité matérielle](../guides/hardware-compatibility.fr.md) | Cartes testées, exigences minimales | ## 🤝 Contribuer & Roadmap Les PRs sont les bienvenues ! Le code source est intentionnellement petit et lisible. -Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](CONTRIBUTING.md) pour les directives. +Consultez notre [Roadmap communautaire](https://github.com/sipeed/picoclaw/issues/988) et [CONTRIBUTING.md](../../CONTRIBUTING.md) pour les directives. Groupe de développeurs en construction, rejoignez-le après votre première PR fusionnée ! @@ -626,4 +627,4 @@ Groupes d'utilisateurs : Discord : WeChat : -WeChat group QR code +WeChat group QR code diff --git a/README.id.md b/docs/project/README.id.md similarity index 83% rename from README.id.md rename to docs/project/README.id.md index 525d4dc72..244e6e49a 100644 --- a/README.id.md +++ b/docs/project/README.id.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Asisten AI Super Ringan berbasis Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | **Bahasa Indonesia** | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif. -2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan. +2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](../../ROADMAP.md) resmi diluncurkan. 2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses. @@ -108,14 +108,14 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O | **Waktu Boot**
(core 0,8GHz) | >500d | >30d | **<1d** | | **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**
**mulai $10** | -PicoClaw +PicoClaw -> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR! +> **[Daftar Kompatibilitas Hardware](../guides/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Demonstrasi @@ -129,9 +129,9 @@ _*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. O

Pencarian Web & Pembelajaran

-

-

-

+

+

+

Develop · Deploy · Scale @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Memulai:** @@ -274,7 +274,7 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena **Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat peringatan keamanan:

-Peringatan macOS Gatekeeper +Peringatan macOS Gatekeeper

> *"picoclaw-launcher" Tidak Dapat Dibuka — Apple tidak dapat memverifikasi bahwa "picoclaw-launcher" bebas dari malware yang dapat membahayakan Mac Anda atau mengancam privasi Anda.* @@ -282,7 +282,7 @@ macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena **Langkah 2:** Buka **Pengaturan Sistem** → **Privasi & Keamanan** → gulir ke bawah ke bagian **Keamanan** → klik **Tetap Buka** → konfirmasi dengan mengklik **Tetap Buka** pada dialog.

-macOS Privasi & Keamanan — Tetap Buka +macOS Privasi & Keamanan — Tetap Buka

Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pada peluncuran berikutnya. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Memulai:** @@ -317,10 +317,10 @@ Pratinjau: - - - - + + + +
@@ -344,7 +344,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Li Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi. -PicoClaw on Termux +PicoClaw on Termux Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON. @@ -450,7 +450,7 @@ PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan fo } ``` -Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md). +Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](../guides/providers.md).
@@ -460,28 +460,28 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan: | Channel | Pengaturan | Protocol | Dokumentasi | |---------|------------|----------|-------------| -| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) | -| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) | -| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) | -| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) | -| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) | -| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) | -| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) | -| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) | -| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) | -| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) | -| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) | -| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) | -| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) | +| **Telegram** | Mudah (bot token) | Long polling | [Panduan](../channels/telegram/README.md) | +| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](../channels/discord/README.md) | +| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](../guides/chat-apps.md#whatsapp) | +| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](../guides/chat-apps.md#weixin) | +| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) | +| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](../channels/slack/README.md) | +| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) | +| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) | +| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](../channels/line/README.md) | +| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) | +| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](../guides/chat-apps.md#irc) | +| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](../channels/onebot/README.md) | +| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) | | **Pico** | Mudah (aktifkan) | Native protocol | Bawaan | | **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan | > Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama. -> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail. +> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.md#gateway-log-level) untuk detail. -Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md). +Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](../guides/chat-apps.md). ## 🔧 Tools @@ -501,7 +501,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t ### ⚙️ Tools Lainnya -PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail. +PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](../reference/tools_configuration.md) untuk detail. ## 🎯 Skills @@ -531,7 +531,7 @@ Tambahkan ke `config.json` Anda: } ``` -Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool). +Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +554,9 @@ PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hub } ``` -Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool). +Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](../reference/tools_configuration.md#mcp-tool). -## ClawdChat Bergabung dengan Jaringan Sosial Agent +## ClawdChat Bergabung dengan Jaringan Sosial Agent Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun. @@ -597,23 +597,23 @@ Untuk panduan lengkap di luar README ini: | Topik | Deskripsi | |-------|-----------| -| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent | -| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel | -| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan | -| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list | -| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | -| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan | -| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup | -| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya | -| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills | -| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum | +| [Docker & Panduan Cepat](../guides/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent | +| [Aplikasi Chat](../guides/chat-apps.md) | Semua 17+ panduan pengaturan channel | +| [Konfigurasi](../guides/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan | +| [Providers & Models](../guides/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list | +| [Spawn & Tugas Async](../guides/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async | +| [Hooks](../architecture/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan | +| [SubTurn](../architecture/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup | +| [Pemecahan Masalah](../operations/troubleshooting.md) | Masalah umum dan solusinya | +| [Konfigurasi Tools](../reference/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills | +| [Kompatibilitas Hardware](../guides/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum | ## 🤝 Kontribusi & Roadmap PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca. -Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan. +Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan. Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge! @@ -622,4 +622,4 @@ Grup Pengguna: Discord: WeChat: -Kode QR grup WeChat +Kode QR grup WeChat diff --git a/README.it.md b/docs/project/README.it.md similarity index 79% rename from README.it.md rename to docs/project/README.it.md index c560976cf..b3db6fece 100644 --- a/README.it.md +++ b/docs/project/README.it.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Assistente IA Ultra-Efficiente in Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive. -2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](ROADMAP.md) pubblicati ufficialmente. +2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](../../ROADMAP.md) pubblicati ufficialmente. 2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio. @@ -108,14 +108,14 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. | **Avvio**
(core 0,8 GHz) | >500s | >30s | **<1s** | | **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**
**a partire da $10** | -PicoClaw +PicoClaw -> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR! +> **[Lista di Compatibilità Hardware](../guides/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Dimostrazione @@ -129,9 +129,9 @@ _*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR.

Ricerca Web & Apprendimento

-

-

-

+

+

+

Sviluppa · Distribuisci · Scala @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Per iniziare:** @@ -274,7 +274,7 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar **Passo 1:** Fai doppio clic su `picoclaw-launcher`. Verrà visualizzato un avviso di sicurezza:

-Avviso macOS Gatekeeper +Avviso macOS Gatekeeper

> *"picoclaw-launcher" Non Aperto — Apple non è riuscita a verificare che "picoclaw-launcher" sia privo di malware che potrebbe danneggiare il Mac o compromettere la privacy.* @@ -282,7 +282,7 @@ macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scar **Passo 2:** Apri **Impostazioni di Sistema** → **Privacy e sicurezza** → scorri fino alla sezione **Sicurezza** → clicca su **Apri comunque** → conferma cliccando su **Apri comunque** nella finestra di dialogo.

-macOS Privacy e sicurezza — Apri comunque +macOS Privacy e sicurezza — Apri comunque

Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai lanci successivi. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Per iniziare:** @@ -317,10 +317,10 @@ Anteprima: - - - - + + + +
@@ -344,7 +344,7 @@ termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del file Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione. -PicoClaw on Termux +PicoClaw on Termux Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON. @@ -450,7 +450,7 @@ PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa i } ``` -Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md). +Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](../guides/providers.md).
@@ -460,28 +460,28 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica: | Channel | Configurazione | Protocollo | Docs | |---------|----------------|------------|------| -| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) | -| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) | -| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) | -| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) | -| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) | -| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) | -| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) | -| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) | -| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) | -| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](docs/channels/wecom/README.md) | -| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) | -| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) | -| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) | +| **Telegram** | Facile (bot token) | Long polling | [Guida](../channels/telegram/README.md) | +| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](../channels/discord/README.md) | +| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](../guides/chat-apps.md#whatsapp) | +| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](../guides/chat-apps.md#weixin) | +| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](../channels/qq/README.md) | +| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](../channels/slack/README.md) | +| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](../channels/matrix/README.md) | +| **DingTalk** | Medio (credenziali client) | Stream | [Guida](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](../channels/feishu/README.md) | +| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](../channels/line/README.md) | +| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](../channels/wecom/README.md) | +| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](../guides/chat-apps.md#irc) | +| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](../channels/onebot/README.md) | +| **MaixCam** | Facile (abilita) | TCP socket | [Guida](../channels/maixcam/README.md) | | **Pico** | Facile (abilita) | Protocollo nativo | Integrato | | **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato | > Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso. -> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli. +> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](../guides/configuration.md#gateway-log-level) per i dettagli. -Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md). +Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](../guides/chat-apps.md). ## 🔧 Strumenti @@ -501,7 +501,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in ### ⚙️ Altri Strumenti -PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli. +PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](../reference/tools_configuration.md) per i dettagli. ## 🎯 Skill @@ -531,7 +531,7 @@ Aggiungi al tuo `config.json`: } ``` -Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool). +Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +554,22 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet } ``` -Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool). +Puoi gestire i casi MCP più comuni direttamente dalla CLI senza modificare a mano il JSON: -## ClawdChat Unisciti al Social Network degli Agent +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +picoclaw mcp list +picoclaw mcp test filesystem +``` + +`picoclaw mcp` agisce come configuration manager: aggiorna `config.json` sotto `tools.mcp.servers`, ma non mantiene in esecuzione il processo del server. + +Usa `picoclaw mcp edit` quando ti servono campi avanzati che non sono coperti da `picoclaw mcp add`. +Per esempio, `picoclaw mcp add` supporta `--deferred` e `--env-file`, mentre `picoclaw mcp edit` resta utile per modifiche JSON dirette e opzioni MCP meno comuni. + +Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). Per la reference della CLI, vedi [MCP Server CLI](../reference/mcp-cli.md). + +## ClawdChat Unisciti al Social Network degli Agent Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata. @@ -574,6 +587,11 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol | `picoclaw status` | Mostra lo stato | | `picoclaw version` | Mostra le info sulla versione | | `picoclaw model` | Visualizza o cambia il modello predefinito | +| `picoclaw mcp list` | Elenca i server MCP configurati | +| `picoclaw mcp add ...` | Aggiunge o aggiorna un server MCP | +| `picoclaw mcp test` | Verifica la raggiungibilità di un server MCP | +| `picoclaw mcp edit` | Apre la config per modifiche MCP avanzate | +| `picoclaw mcp remove` | Rimuove un server MCP dalla config | | `picoclaw cron list` | Elenca tutti i job pianificati | | `picoclaw cron add ...` | Aggiunge un job pianificato | | `picoclaw cron disable` | Disabilita un job pianificato | @@ -597,23 +615,24 @@ Per guide dettagliate oltre questo README: | Argomento | Descrizione | |-----------|-------------| -| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | -| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel | -| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza | -| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list | -| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | -| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione | -| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita | -| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni | -| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill | -| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi | +| [Docker & Avvio Rapido](../guides/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | +| [App di Chat](../guides/chat-apps.md) | Tutte le guide di configurazione per 17+ channel | +| [Configurazione](../guides/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza | +| [MCP Server CLI](../reference/mcp-cli.md) | Aggiunta, elenco, test, modifica e rimozione dei server MCP da CLI | +| [Provider & Modelli](../guides/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list | +| [Spawn & Task Asincroni](../guides/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | +| [Hooks](../architecture/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Iniettare messaggi in un loop agent in esecuzione | +| [SubTurn](../architecture/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita | +| [Risoluzione Problemi](../operations/troubleshooting.md) | Problemi comuni e soluzioni | +| [Configurazione degli Strumenti](../reference/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill | +| [Compatibilità Hardware](../guides/hardware-compatibility.md) | Schede testate, requisiti minimi | ## 🤝 Contribuisci & Roadmap Le PR sono benvenute! Il codice è volutamente piccolo e leggibile. -Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida. +Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) per le linee guida. Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata! @@ -622,4 +641,4 @@ Gruppi utenti: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.ja.md b/docs/project/README.ja.md similarity index 84% rename from README.ja.md rename to docs/project/README.ja.md index d09eb436d..66d06ba5e 100644 --- a/README.ja.md +++ b/docs/project/README.ja.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | **日本語** | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw がわずか 17 日で **20K スター** 達成!Channel 自動オーケストレーションとケイパビリティインターフェースが実装されました。 -2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](ROADMAP.md)が正式に公開されました。 +2026-02-16 🎉 PicoClaw が 1 週間で 12K スター達成!コミュニティメンテナーの役割と[ロードマップ](../../ROADMAP.md)が正式に公開されました。 2026-02-13 🎉 PicoClaw が 4 日間で 5000 スター達成!プロジェクトロードマップと開発者グループの準備が進行中。 @@ -108,14 +108,14 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな | **起動時間**
(0.8GHz コア) | >500秒 | >30秒 | **<1秒** | | **コスト** | Mac Mini $599 | 大半の Linux ボード ~$50 | **あらゆる Linux ボード**
**最安 $10** | -PicoClaw +PicoClaw -> **[ハードウェア互換性リスト](docs/ja/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください! +> **[ハードウェア互換性リスト](../guides/hardware-compatibility.ja.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 デモンストレーション @@ -129,9 +129,9 @@ _*最近のバージョンでは急速な PR マージにより 10〜20MB にな

Web 検索&学習

-

-

-

+

+

+

開発 · デプロイ · スケール @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**始め方:** @@ -274,7 +274,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d **ステップ 1:** `picoclaw-launcher` をダブルクリックすると、セキュリティ警告が表示されます:

-macOS Gatekeeper 警告 +macOS Gatekeeper 警告

> *"picoclaw-launcher" は開けません — "picoclaw-launcher" がMacに害を与えたりプライバシーを侵害するマルウェアを含まないことをAppleは確認できません。* @@ -282,7 +282,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d **ステップ 2:** **システム設定** → **プライバシーとセキュリティ** を開き、**セキュリティ** セクションまでスクロールして **このまま開く** をクリック → ダイアログで再度 **開く** をクリックします。

-macOS プライバシーとセキュリティ — このまま開く +macOS プライバシーとセキュリティ — このまま開く

この操作を一度行うと、以降の起動では警告が表示されなくなります。 @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**始め方:** @@ -307,6 +307,7 @@ TUI メニューを使って:**1)** Provider を設定 → **2)** Channel を TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.io) を参照してください。 + ### 📱 Android 10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。 @@ -317,10 +318,10 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル その後、下記の Terminal Launcher セクションの手順に従って設定を完了してください。 -PicoClaw on Termux +PicoClaw on Termux `picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。 @@ -450,7 +451,7 @@ PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポ } ``` -Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.md) を参照してください。 +Provider の完全な設定詳細は [Provider とモデル](../guides/providers.ja.md) を参照してください。
@@ -460,28 +461,28 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m | Channel | セットアップ | Protocol | ドキュメント | |---------|------------|----------|------------| -| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](docs/channels/telegram/README.ja.md) | -| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](docs/channels/discord/README.ja.md) | -| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](docs/ja/chat-apps.md#whatsapp) | -| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](docs/ja/chat-apps.md#weixin) | -| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](docs/channels/qq/README.ja.md) | -| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](docs/channels/slack/README.ja.md) | -| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](docs/channels/matrix/README.ja.md) | -| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](docs/channels/dingtalk/README.ja.md) | -| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](docs/channels/feishu/README.ja.md) | -| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](docs/channels/line/README.ja.md) | -| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](docs/channels/wecom/README.md) | -| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](docs/ja/chat-apps.md#irc) | -| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](docs/channels/onebot/README.ja.md) | -| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](docs/channels/maixcam/README.ja.md) | +| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](../channels/telegram/README.ja.md) | +| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](../channels/discord/README.ja.md) | +| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](../guides/chat-apps.ja.md#whatsapp) | +| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](../guides/chat-apps.ja.md#weixin) | +| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](../channels/qq/README.ja.md) | +| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](../channels/slack/README.ja.md) | +| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](../channels/matrix/README.ja.md) | +| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](../channels/dingtalk/README.ja.md) | +| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](../channels/feishu/README.ja.md) | +| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](../channels/line/README.ja.md) | +| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](../channels/wecom/README.ja.md) | +| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](../guides/chat-apps.ja.md#irc) | +| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](../channels/onebot/README.ja.md) | +| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](../channels/maixcam/README.ja.md) | | **Pico** | 簡単(有効化) | Native protocol | 内蔵 | | **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 | > webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。 -> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。 +> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](../guides/configuration.ja.md#gateway-ログレベル)を参照してください。 -Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。 +Channel の詳細なセットアップ手順は [チャットアプリ設定](../guides/chat-apps.ja.md) を参照してください。 ## 🔧 ツール @@ -501,7 +502,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to ### ⚙️ その他のツール -PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](docs/ja/tools_configuration.md) を参照してください。 +PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](../reference/tools_configuration.ja.md) を参照してください。 ## 🎯 Skill @@ -531,7 +532,7 @@ picoclaw skills install } ``` -詳細は [ツール設定 - Skill](docs/ja/tools_configuration.md#skills-tool) を参照してください。 +詳細は [ツール設定 - Skill](../reference/tools_configuration.ja.md#skills-tool) を参照してください。 ## 🔗 MCP(Model Context Protocol) @@ -554,9 +555,9 @@ PicoClaw は [MCP](https://modelcontextprotocol.io/) をネイティブサポー } ``` -MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](docs/ja/tools_configuration.md#mcp-tool) を参照してください。 +MCP の完全な設定(stdio、SSE、HTTP トランスポート、Tool Discovery)は [ツール設定 - MCP](../reference/tools_configuration.ja.md#mcp-tool) を参照してください。 -## ClawdChat エージェントソーシャルネットワークに参加 +## ClawdChat エージェントソーシャルネットワークに参加 CLI または統合チャットアプリからメッセージを 1 つ送るだけで、PicoClaw をエージェントソーシャルネットワークに接続できます。 @@ -597,23 +598,23 @@ PicoClaw は `cron` ツールによるスケジュールリマインダーと定 | トピック | 説明 | |---------|------| -| [Docker & クイックスタート](docs/ja/docker.md) | Docker Compose セットアップ、Launcher/Agent モード | -| [チャットアプリ](docs/ja/chat-apps.md) | 17 以上の Channel セットアップガイド | -| [設定](docs/ja/configuration.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス | -| [Provider とモデル](docs/ja/providers.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 | -| [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | -| [Hook システム](docs/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | -| [Steering](docs/steering.md) | 実行中の Agent ループにメッセージを注入 | -| [SubTurn](docs/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | -| [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 | -| [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill | -| [ハードウェア互換性](docs/ja/hardware-compatibility.md) | テスト済みボード、最小要件 | +| [Docker & クイックスタート](../guides/docker.ja.md) | Docker Compose セットアップ、Launcher/Agent モード | +| [チャットアプリ](../guides/chat-apps.ja.md) | 17 以上の Channel セットアップガイド | +| [設定](../guides/configuration.ja.md) | 環境変数、ワークスペース構成、セキュリティサンドボックス | +| [Provider とモデル](../guides/providers.ja.md) | 30 以上の LLM Provider、モデルルーティング、model_list 設定 | +| [Spawn & 非同期タスク](../guides/spawn-tasks.ja.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション | +| [Hook システム](../architecture/hooks/README.md) | イベント駆動 Hook:オブザーバー、インターセプター、承認 Hook | +| [Steering](../architecture/steering.md) | 実行中の Agent ループにメッセージを注入 | +| [SubTurn](../architecture/subturn.md) | サブ Agent の調整、並行制御、ライフサイクル | +| [トラブルシューティング](../operations/troubleshooting.ja.md) | よくある問題と解決策 | +| [ツール設定](../reference/tools_configuration.ja.md) | ツールごとの有効/無効、exec ポリシー、MCP、Skill | +| [ハードウェア互換性](../guides/hardware-compatibility.ja.md) | テスト済みボード、最小要件 | ## 🤝 コントリビュート&ロードマップ PR 歓迎!コードベースは意図的に小さく読みやすくしています。 -[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](CONTRIBUTING.md)をご覧ください。 +[コミュニティロードマップ](https://github.com/sipeed/picoclaw/issues/988)と[CONTRIBUTING.md](../../CONTRIBUTING.md)をご覧ください。 開発者グループ構築中、最初の PR がマージされたら参加できます! @@ -622,4 +623,4 @@ PR 歓迎!コードベースは意図的に小さく読みやすくしてい Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.ko.md b/docs/project/README.ko.md similarity index 83% rename from README.ko.md rename to docs/project/README.ko.md index 9095a9240..cfc985688 100644 --- a/README.ko.md +++ b/docs/project/README.ko.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Go로 작성된 초고효율 AI 어시스턴트

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | **한국어** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw가 단 17일 만에 **20K 스타**를 달성했습니다! 채널 자동 오케스트레이션과 기능 인터페이스가 적용되었습니다. -2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](ROADMAP.md)이 공식적으로 공개되었습니다. +2026-02-16 🎉 PicoClaw가 1주일 만에 **12K 스타**를 돌파했습니다! 커뮤니티 메인터너 역할과 [로드맵](../../ROADMAP.md)이 공식적으로 공개되었습니다. 2026-02-13 🎉 PicoClaw가 4일 만에 **5000 스타**를 돌파했습니다! 프로젝트 로드맵과 개발자 그룹이 준비 중입니다. @@ -108,14 +108,14 @@ _*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있 | **부팅 시간**
(0.8GHz 코어) | >500초 | >30초 | **<1초** | | **비용** | Mac Mini $599 | 대부분의 Linux 보드 ~$50 | **모든 Linux 보드**
**최저 $10부터** | -PicoClaw +PicoClaw -> **[하드웨어 호환 목록](docs/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요! +> **[하드웨어 호환 목록](../guides/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 데모 @@ -129,9 +129,9 @@ _*최근 빌드는 급격한 PR 병합으로 인해 10~20MB를 사용할 수 있

웹 검색 및 학습

-

-

-

+

+

+

개발 · 배포 · 확장 @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**시작 방법:** @@ -274,7 +274,7 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 **1단계:** `picoclaw-launcher`를 더블클릭합니다. 그러면 보안 경고가 표시됩니다.

-macOS Gatekeeper warning +macOS Gatekeeper warning

> *"picoclaw-launcher"을(를) 열 수 없습니다. Apple에서 이 앱이 악성 소프트웨어가 없으며 Mac이나 개인 정보를 해치지 않는다고 확인할 수 없습니다.* @@ -282,7 +282,7 @@ macOS에서는 인터넷에서 다운로드한 앱이고 Mac App Store 공증을 **2단계:** **시스템 설정** -> **개인정보 보호 및 보안** 으로 이동한 뒤 **보안** 섹션까지 스크롤하여 **그래도 열기(Open Anyway)** 를 클릭하고, 대화상자에서 다시 한 번 **그래도 열기**를 확인합니다.

-macOS Privacy & Security — Open Anyway +macOS Privacy & Security — Open Anyway

이 과정을 한 번만 거치면 이후에는 `picoclaw-launcher`가 정상적으로 열립니다. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**시작 방법:** @@ -317,10 +317,10 @@ TUI 메뉴를 사용해 다음 순서로 진행하세요. **1)** 프로바이더 - - - - + + + +
@@ -344,7 +344,7 @@ termux-chroot ./picoclaw onboard # chroot가 표준 Linux 파일시스템 레 그다음 아래의 터미널 런처 섹션을 따라 설정을 마무리하세요. -PicoClaw on Termux +PicoClaw on Termux 런처 UI 없이 `picoclaw` 코어 바이너리만 있는 최소 환경에서는 명령줄과 JSON 설정 파일만으로도 모든 설정을 마칠 수 있습니다. @@ -377,7 +377,7 @@ picoclaw onboard > 사용 가능한 모든 옵션이 포함된 전체 설정 템플릿은 저장소의 `config/config.example.json`을 참고하세요. > -> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `docs/security_configuration.md`를 참고하세요. +> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `../security/security_configuration.md`를 참고하세요. **3. 채팅** @@ -455,7 +455,7 @@ PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 } ``` -프로바이더 전체 설정은 [프로바이더와 모델](docs/providers.md)을 참고하세요. +프로바이더 전체 설정은 [프로바이더와 모델](../guides/providers.md)을 참고하세요. @@ -465,29 +465,29 @@ PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를 | 채널 | 설정 | 프로토콜 | 문서 | |---------|------|----------|------| -| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](docs/channels/telegram/README.md) | -| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](docs/channels/discord/README.md) | -| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](docs/chat-apps.md#whatsapp) | -| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](docs/chat-apps.md#weixin) | -| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](docs/channels/qq/README.md) | -| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](docs/channels/slack/README.md) | -| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](docs/channels/matrix/README.md) | -| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](docs/channels/feishu/README.md) | -| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](docs/channels/line/README.md) | -| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](docs/channels/wecom/README.md) | -| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](docs/channels/vk/README.md) | -| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](docs/chat-apps.md#irc) | -| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](docs/channels/onebot/README.md) | -| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](docs/channels/maixcam/README.md) | +| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](../channels/telegram/README.md) | +| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](../channels/discord/README.md) | +| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](../guides/chat-apps.md#whatsapp) | +| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](../guides/chat-apps.md#weixin) | +| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](../channels/qq/README.md) | +| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](../channels/slack/README.md) | +| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](../channels/matrix/README.md) | +| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](../channels/dingtalk/README.md) | +| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](../channels/feishu/README.md) | +| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](../channels/line/README.md) | +| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](../channels/wecom/README.md) | +| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](../channels/vk/README.md) | +| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](../guides/chat-apps.md#irc) | +| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](../channels/onebot/README.md) | +| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](../channels/maixcam/README.md) | | **Pico** | 쉬움(활성화) | 네이티브 프로토콜 | 내장 | | **Pico Client** | 쉬움(WebSocket URL) | WebSocket | 내장 | > webhook 기반 채널은 모두 하나의 게이트웨이 HTTP 서버(`gateway.host`:`gateway.port`, 기본값 `127.0.0.1:18790`)를 공유합니다. Feishu는 WebSocket/SDK 모드를 사용하며 이 공용 HTTP 서버를 사용하지 않습니다. -> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](docs/configuration.md#gateway-log-level)를 참고하세요. +> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](../guides/configuration.md#gateway-log-level)를 참고하세요. -자세한 채널 설정 방법은 [채팅 앱 설정 가이드](docs/chat-apps.md)를 참고하세요. +자세한 채널 설정 방법은 [채팅 앱 설정 가이드](../guides/chat-apps.md)를 참고하세요. ## 🔧 도구 @@ -507,7 +507,7 @@ PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있 ### ⚙️ 기타 도구 -PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](docs/tools_configuration.md)을 참고하세요. +PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](../reference/tools_configuration.md)을 참고하세요. ## 🎯 스킬 @@ -537,7 +537,7 @@ picoclaw skills install } ``` -자세한 내용은 [도구 설정 - 스킬](docs/tools_configuration.md#skills-tool)를 참고하세요. +자세한 내용은 [도구 설정 - 스킬](../reference/tools_configuration.md#skills-tool)를 참고하세요. ## 🔗 MCP (Model Context Protocol) @@ -560,9 +560,9 @@ PicoClaw는 [MCP](https://modelcontextprotocol.io/)를 기본 지원합니다. } ``` -MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](docs/tools_configuration.md#mcp-tool)를 참고하세요. +MCP 전체 설정(stdio, SSE, HTTP 전송 방식, 도구 탐색)은 [도구 설정 - MCP](../reference/tools_configuration.md#mcp-tool)를 참고하세요. -## ClawdChat 에이전트 소셜 네트워크 참여하기 +## ClawdChat 에이전트 소셜 네트워크 참여하기 CLI 또는 통합된 채팅 앱에서 메시지를 한 번만 보내면 PicoClaw를 에이전트 소셜 네트워크에 연결할 수 있습니다. @@ -597,7 +597,7 @@ PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지 * **반복 작업**: "2시간마다 알려줘" -> 2시간마다 실행 * **Cron 표현식**: "매일 오전 9시에 알려줘" -> cron 표현식 사용 -현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/cron.md](docs/cron.md)를 참고하세요. +현재 지원하는 스케줄 유형, 실행 모드, 명령 작업 게이트, 저장 방식은 [docs/reference/cron.md](../reference/cron.md)를 참고하세요. ## 📚 문서 @@ -605,24 +605,24 @@ PicoClaw는 `cron` 도구를 통해 예약 리마인더와 반복 작업을 지 | 주제 | 설명 | |------|------| -| [도커 & 빠른 시작](docs/docker.md) | Docker Compose 설정, 런처/에이전트 모드 | -| [채팅 앱](docs/chat-apps.md) | 17개 이상의 채널 설정 가이드 | -| [설정](docs/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 | -| [예약 작업과 Cron](docs/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 | -| [프로바이더와 모델](docs/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 | -| [Spawn & 비동기 작업](docs/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 | -| [Hooks](docs/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 | -| [Steering](docs/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 | -| [SubTurn](docs/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 | -| [문제 해결](docs/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 | -| [도구 설정](docs/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 | -| [하드웨어 호환성](docs/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 | +| [도커 & 빠른 시작](../guides/docker.md) | Docker Compose 설정, 런처/에이전트 모드 | +| [채팅 앱](../guides/chat-apps.md) | 17개 이상의 채널 설정 가이드 | +| [설정](../guides/configuration.md) | 환경 변수, 워크스페이스 레이아웃, 보안 샌드박스 | +| [예약 작업과 Cron](../reference/cron.md) | Cron 스케줄 유형, 전달 모드, 명령 게이트, 작업 저장 | +| [프로바이더와 모델](../guides/providers.md) | 30개 이상의 LLM 프로바이더, 모델 라우팅, model_list 설정 | +| [Spawn & 비동기 작업](../guides/spawn-tasks.md) | 빠른 작업, spawn을 이용한 장기 작업, 비동기 서브에이전트 오케스트레이션 | +| [Hooks](../architecture/hooks/README.md) | 이벤트 기반 Hook 시스템: 관찰자, 인터셉터, 승인 훅 | +| [Steering](../architecture/steering.md) | 실행 중인 에이전트 루프에서 도구 호출 사이에 메시지 주입 | +| [SubTurn](../architecture/subturn.md) | 서브에이전트 조정, 동시성 제어, 생명주기 | +| [문제 해결](../operations/troubleshooting.md) | 자주 발생하는 문제와 해결 방법 | +| [도구 설정](../reference/tools_configuration.md) | 도구별 활성화/비활성화, exec 정책, MCP, 스킬 | +| [하드웨어 호환성](../guides/hardware-compatibility.md) | 테스트된 보드, 최소 요구사항 | ## 🤝 기여 & 로드맵 PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽기 쉽게 유지하고 있습니다. -가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요. +가이드라인은 [커뮤니티 로드맵](https://github.com/sipeed/picoclaw/issues/988)과 [CONTRIBUTING.md](../../CONTRIBUTING.md)를 참고하세요. 개발자 그룹도 준비 중입니다. 첫 PR이 머지되면 함께할 수 있습니다! @@ -631,4 +631,4 @@ PR은 언제든 환영합니다! 코드베이스는 의도적으로 작고 읽 Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.my.md b/docs/project/README.ms.md similarity index 85% rename from README.my.md rename to docs/project/README.ms.md index bbe003deb..f8c9e95e7 100644 --- a/README.my.md +++ b/docs/project/README.ms.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Pembantu AI Ultra-Cekap dalam Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi saluran automatik dan antara muka keupayaan kini aktif. -2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](ROADMAP.md) dilancarkan secara rasmi. +2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](../../ROADMAP.md) dilancarkan secara rasmi. 2026-02-13 🎉 PicoClaw melepasi 5000 Stars dalam 4 hari! Peta jalan projek dan kumpulan pembangun sedang dalam proses. @@ -108,14 +108,14 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes | **Masa Boot** (teras 0.8GHz) | >500s | >30s | **<1s** | | **Kos** | Mac Mini $599 | Kebanyakan papan Linux ~$50 | **Mana-mana papan Linux dari $10** | -PicoClaw +PicoClaw -> **[Senarai Keserasian Perkakasan](docs/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android. +> **[Senarai Keserasian Perkakasan](../guides/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android.

-Keserasian Perkakasan PicoClaw +Keserasian Perkakasan PicoClaw

## 🦾 Demonstrasi @@ -129,9 +129,9 @@ _*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pes

Carian Web & Pembelajaran

-

-

-

+

+

+

Bangun · Deploy · Skala @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-Pelancar WebUI +Pelancar WebUI

**Memulakan:** Buka WebUI, kemudian: **1)** Konfigurasikan Penyedia (tambah kunci API LLM) -> **2)** Konfigurasikan Saluran (cth. Telegram) -> **3)** Mulakan Gateway -> **4)** Sembang! @@ -271,7 +271,7 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim **Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat amaran keselamatan:

-Amaran macOS Gatekeeper +Amaran macOS Gatekeeper

> *"picoclaw-launcher" Tidak Dibuka — Apple tidak dapat mengesahkan "picoclaw-launcher" bebas daripada perisian hasad yang mungkin membahayakan Mac anda atau menjejaskan privasi anda.* @@ -279,7 +279,7 @@ macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dim **Langkah 2:** Buka **Tetapan Sistem** → **Privasi & Keselamatan** → tatal ke bawah ke bahagian **Keselamatan** → klik **Buka Juga** → sahkan dengan mengklik **Buka Juga** dalam dialog.

-macOS Privasi & Keselamatan — Buka Juga +macOS Privasi & Keselamatan — Buka Juga

Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada pelancaran seterusnya. @@ -295,7 +295,7 @@ picoclaw-launcher-tui ```

-Pelancar TUI +Pelancar TUI

**Memulakan:** @@ -314,10 +314,10 @@ Pratonton: - - - - + + + +
@@ -341,7 +341,7 @@ termux-chroot ./picoclaw onboard # chroot menyediakan susun atur sistem fail L Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfigurasi. -PicoClaw pada Termux +PicoClaw pada Termux Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON. @@ -449,7 +449,7 @@ PicoClaw menyokong 30+ penyedia LLM melalui konfigurasi `model_list`. Gunakan fo } ``` -Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](docs/providers.md). +Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](../guides/providers.md). @@ -460,28 +460,28 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan: | Saluran | Persediaan | Protokol | Dok | |---------|-----------|----------|-----| -| **Telegram** | Mudah (token bot) | Long polling | [Panduan](docs/channels/telegram/README.md) | -| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) | -| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](docs/chat-apps.md#whatsapp) | -| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](docs/chat-apps.md#weixin) | -| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) | -| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](docs/channels/slack/README.md) | -| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) | -| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](docs/channels/dingtalk/README.md) | -| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) | -| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](docs/channels/line/README.md) | -| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) | -| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](docs/chat-apps.md#irc) | -| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) | -| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) | +| **Telegram** | Mudah (token bot) | Long polling | [Panduan](../channels/telegram/README.md) | +| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](../channels/discord/README.md) | +| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](../guides/chat-apps.ms.md#whatsapp) | +| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](../guides/chat-apps.ms.md#weixin) | +| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) | +| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](../channels/slack/README.md) | +| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) | +| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](../channels/dingtalk/README.md) | +| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) | +| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](../channels/line/README.md) | +| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) | +| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](../guides/chat-apps.ms.md#irc) | +| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](../channels/onebot/README.md) | +| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) | | **Pico** | Mudah (aktifkan) | Protokol natif | Terbina dalam | | **Pico Client** | Mudah (URL WebSocket) | WebSocket | Terbina dalam | > Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi. -> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran. +> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.ms.md#gateway-log-level) untuk butiran. -Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md). +Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](../guides/chat-apps.ms.md). ## 🔧 Alat @@ -501,7 +501,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da ### ⚙️ Alat Lain -PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](docs/tools_configuration.md) untuk butiran. +PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](../reference/tools_configuration.md) untuk butiran. ## 🎯 Kemahiran @@ -531,7 +531,7 @@ Tambah ke `config.json` anda: } ``` -Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](docs/tools_configuration.md#skills-tool). +Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](../reference/tools_configuration.md#skills-tool). ## 🔗 MCP (Protokol Konteks Model) @@ -554,9 +554,9 @@ PicoClaw menyokong [MCP](https://modelcontextprotocol.io/) secara natif — samb } ``` -Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](docs/tools_configuration.md#mcp-tool). +Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](../reference/tools_configuration.md#mcp-tool). -## ClawdChat Sertai Rangkaian Sosial Agent +## ClawdChat Sertai Rangkaian Sosial Agent Sambungkan PicoClaw ke Rangkaian Sosial Agent dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan. @@ -597,20 +597,20 @@ Untuk panduan terperinci melebihi README ini: | Topik | Penerangan | |-------|------------| -| [Docker & Permulaan Pantas](docs/my/docker.md) | Persediaan Docker Compose, mod Launcher/Agent | -| [Aplikasi Sembang](docs/my/chat-apps.md) | Panduan persediaan 17+ saluran | -| [Konfigurasi](docs/my/configuration.md) | Pemboleh ubah persekitaran, susun atur ruang kerja | -| [Penyedia & Model](docs/providers.md) | 30+ penyedia LLM, penghalaan model | -| [Spawn & Tugasan Async](docs/my/spawn-tasks.md) | Tugasan pantas, tugasan panjang dengan spawn | -| [Penyelesaian Masalah](docs/my/troubleshooting.md) | Isu biasa dan penyelesaian | -| [Konfigurasi Alat](docs/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran | -| [Keserasian Perkakasan](docs/hardware-compatibility.md) | Papan yang diuji, keperluan minimum | +| [Docker & Permulaan Pantas](../guides/docker.ms.md) | Persediaan Docker Compose, mod Launcher/Agent | +| [Aplikasi Sembang](../guides/chat-apps.ms.md) | Panduan persediaan 17+ saluran | +| [Konfigurasi](../guides/configuration.ms.md) | Pemboleh ubah persekitaran, susun atur ruang kerja | +| [Penyedia & Model](../guides/providers.md) | 30+ penyedia LLM, penghalaan model | +| [Spawn & Tugasan Async](../guides/spawn-tasks.ms.md) | Tugasan pantas, tugasan panjang dengan spawn | +| [Penyelesaian Masalah](../operations/troubleshooting.ms.md) | Isu biasa dan penyelesaian | +| [Konfigurasi Alat](../reference/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran | +| [Keserasian Perkakasan](../guides/hardware-compatibility.md) | Papan yang diuji, keperluan minimum | ## 🤝 Sumbangan & Peta Jalan PR dialu-alukan! Kod sumber sengaja dibuat kecil dan mudah dibaca. -Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan. +Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](../../CONTRIBUTING.md) untuk panduan. Kumpulan pembangun sedang dibina, sertai selepas PR pertama anda digabungkan! @@ -619,4 +619,4 @@ Kumpulan Pengguna: Discord: WeChat: -Kod QR kumpulan WeChat +Kod QR kumpulan WeChat diff --git a/README.pt-br.md b/docs/project/README.pt-br.md similarity index 82% rename from README.pt-br.md rename to docs/project/README.pt-br.md index 25f82a180..56d4ddd63 100644 --- a/README.pt-br.md +++ b/docs/project/README.pt-br.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Assistente de IA Ultra-Eficiente em Go

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 O PicoClaw atinge **20K Stars** em apenas 17 dias! Orquestração automática de channels e interfaces de capacidade estão disponíveis. -2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](ROADMAP.md) lançados oficialmente. +2026-02-16 🎉 O PicoClaw ultrapassa 12K Stars em uma semana! Funções de mantenedor da comunidade e [Roadmap](../../ROADMAP.md) lançados oficialmente. 2026-02-13 🎉 O PicoClaw ultrapassa 5000 Stars em 4 dias! Roadmap do projeto e grupos de desenvolvedores em andamento. @@ -108,14 +108,14 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç | **Tempo de boot**
(core 0,8GHz) | >500s | >30s | **<1s** | | **Custo** | Mac Mini $599 | Maioria das placas Linux ~$50 | **Qualquer placa Linux**
**a partir de $10** | -PicoClaw +PicoClaw -> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR! +> **[Lista de Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Demonstração @@ -129,9 +129,9 @@ _*Builds recentes podem usar 10-20MB devido a merges rápidos de PRs. Otimizaç

Busca na Web e Aprendizado

-

-

-

+

+

+

Desenvolver · Implantar · Escalar @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Primeiros passos:** @@ -274,7 +274,7 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f **Passo 1:** Dê um duplo clique em `picoclaw-launcher`. Você verá um aviso de segurança:

-Aviso do macOS Gatekeeper +Aviso do macOS Gatekeeper

> *"picoclaw-launcher" não foi aberto — A Apple não conseguiu verificar se "picoclaw-launcher" está livre de malware que possa prejudicar seu Mac ou comprometer sua privacidade.* @@ -282,7 +282,7 @@ O macOS pode bloquear o `picoclaw-launcher` no primeiro lançamento porque ele f **Passo 2:** Abra **Configurações do Sistema** → **Privacidade e Segurança** → role até a seção **Segurança** → clique em **Abrir Mesmo Assim** → confirme clicando em **Abrir Mesmo Assim** na caixa de diálogo.

-macOS Privacidade e Segurança — Abrir Mesmo Assim +macOS Privacidade e Segurança — Abrir Mesmo Assim

Após esta etapa única, o `picoclaw-launcher` abrirá normalmente nos lançamentos seguintes. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Primeiros passos:** @@ -307,6 +307,7 @@ Use os menus do TUI para: **1)** Configurar um Provider -> **2)** Configurar um Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw. @@ -317,10 +318,10 @@ Pré-visualização: - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot fornece um layout padrão de sistema Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuração. -PicoClaw on Termux +PicoClaw on Termux Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON. @@ -450,7 +451,7 @@ O PicoClaw suporta mais de 30 providers de LLM através da configuração `model } ``` -Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md). +Para detalhes completos de configuração de providers, veja [Providers & Models](../guides/providers.pt-br.md). @@ -460,28 +461,28 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens: | Channel | Configuração | Protocolo | Docs | |---------|--------------|-----------|------| -| **Telegram** | Fácil (bot token) | Long polling | [Guia](docs/channels/telegram/README.pt-br.md) | -| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) | -| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) | -| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) | -| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) | -| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) | -| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) | -| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) | -| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) | -| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) | -| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](docs/channels/wecom/README.md) | -| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) | -| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) | -| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) | +| **Telegram** | Fácil (bot token) | Long polling | [Guia](../channels/telegram/README.pt-br.md) | +| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](../channels/discord/README.pt-br.md) | +| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](../guides/chat-apps.pt-br.md#whatsapp) | +| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](../guides/chat-apps.pt-br.md#weixin) | +| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](../channels/qq/README.pt-br.md) | +| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](../channels/slack/README.pt-br.md) | +| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](../channels/matrix/README.pt-br.md) | +| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](../channels/dingtalk/README.pt-br.md) | +| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](../channels/feishu/README.pt-br.md) | +| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](../channels/line/README.pt-br.md) | +| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](../channels/wecom/README.pt-br.md) | +| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](../guides/chat-apps.pt-br.md#irc) | +| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](../channels/onebot/README.pt-br.md) | +| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](../channels/maixcam/README.pt-br.md) | | **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado | | **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado | > Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado. -> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes. +> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](../guides/configuration.pt-br.md#nível-de-log-do-gateway) para detalhes. -Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md). +Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](../guides/chat-apps.pt-br.md). ## 🔧 Ferramentas @@ -501,7 +502,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config ### ⚙️ Outras Ferramentas -O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) para detalhes. +O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) para detalhes. ## 🎯 Skills @@ -531,7 +532,7 @@ Adicione ao seu `config.json`: } ``` -Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool). +Para mais detalhes, veja [Configuração de Ferramentas - Skills](../reference/tools_configuration.pt-br.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +555,9 @@ O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — con } ``` -Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool). +Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](../reference/tools_configuration.pt-br.md#mcp-tool). -## ClawdChat Junte-se à Rede Social de Agents +## ClawdChat Junte-se à Rede Social de Agents Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado. @@ -597,23 +598,23 @@ Para guias detalhados além deste README: | Tópico | Descrição | |--------|-----------| -| [Docker & Início Rápido](docs/pt-br/docker.md) | Configuração do Docker Compose, modos Launcher/Agent | -| [Apps de Chat](docs/pt-br/chat-apps.md) | Guias de configuração para todos os 17+ channels | -| [Configuração](docs/pt-br/configuration.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança | -| [Providers & Models](docs/pt-br/providers.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list | -| [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents | -| [Hooks](docs/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação | -| [Steering](docs/steering.md) | Injetar mensagens em um loop de agente em execução | -| [SubTurn](docs/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | -| [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções | -| [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills | -| [Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md) | Placas testadas, requisitos mínimos | +| [Docker & Início Rápido](../guides/docker.pt-br.md) | Configuração do Docker Compose, modos Launcher/Agent | +| [Apps de Chat](../guides/chat-apps.pt-br.md) | Guias de configuração para todos os 17+ channels | +| [Configuração](../guides/configuration.pt-br.md) | Variáveis de ambiente, layout do workspace, sandbox de segurança | +| [Providers & Models](../guides/providers.pt-br.md) | 30+ providers de LLM, roteamento de modelos, configuração de model_list | +| [Spawn & Tarefas Assíncronas](../guides/spawn-tasks.pt-br.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agents | +| [Hooks](../architecture/hooks/README.md) | Sistema de hooks orientado a eventos: observadores, interceptores, hooks de aprovação | +| [Steering](../architecture/steering.md) | Injetar mensagens em um loop de agente em execução | +| [SubTurn](../architecture/subturn.md) | Coordenação de subagentes, controle de concorrência, ciclo de vida | +| [Solução de Problemas](../operations/troubleshooting.pt-br.md) | Problemas comuns e soluções | +| [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) | Habilitar/desabilitar por ferramenta, políticas de exec, MCP, Skills | +| [Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md) | Placas testadas, requisitos mínimos | ## 🤝 Contribuir & Roadmap PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. -Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) para diretrizes. +Veja nosso [Roadmap da Comunidade](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](../../CONTRIBUTING.md) para diretrizes. Grupo de desenvolvedores em formação, entre após seu primeiro PR mesclado! @@ -622,4 +623,4 @@ Grupos de Usuários: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.vi.md b/docs/project/README.vi.md similarity index 84% rename from README.vi.md rename to docs/project/README.vi.md index 98e0b9bc9..52a56796b 100644 --- a/README.vi.md +++ b/docs/project/README.vi.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +[中文](README.zh.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw đạt **20K Stars** chỉ trong 17 ngày! Tự động điều phối Channel và giao diện khả năng đã hoạt động. -2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](ROADMAP.md) chính thức ra mắt. +2026-02-16 🎉 PicoClaw vượt 12K Stars trong một tuần! Vai trò người duy trì cộng đồng và [Lộ trình](../../ROADMAP.md) chính thức ra mắt. 2026-02-13 🎉 PicoClaw vượt 5000 Stars trong 4 ngày! Lộ trình dự án và nhóm nhà phát triển đang được xây dựng. @@ -108,14 +108,14 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối | **Thời gian khởi động**
(lõi 0.8GHz) | >500s | >30s | **<1s** | | **Chi phí** | Mac Mini $599 | Hầu hết board Linux ~$50 | **Bất kỳ board Linux**
**từ $10** | -PicoClaw +PicoClaw -> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR! +> **[Danh sách Tương thích Phần cứng](../guides/hardware-compatibility.vi.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 Minh họa @@ -129,9 +129,9 @@ _*Các bản build gần đây có thể dùng 10-20MB do merge PR nhanh. Tối

Tìm kiếm Web & Học tập

-

-

-

+

+

+

Phát triển · Triển khai · Mở rộng @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**Bắt đầu:** @@ -274,7 +274,7 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì n **Bước 1:** Nhấp đúp vào `picoclaw-launcher`. Bạn sẽ thấy cảnh báo bảo mật:

-Cảnh báo macOS Gatekeeper +Cảnh báo macOS Gatekeeper

> *"picoclaw-launcher" Không Mở Được — Apple không thể xác minh "picoclaw-launcher" không chứa phần mềm độc hại có thể gây hại cho Mac hoặc xâm phạm quyền riêng tư của bạn.* @@ -282,7 +282,7 @@ macOS có thể chặn `picoclaw-launcher` khi khởi chạy lần đầu vì n **Bước 2:** Mở **Cài đặt Hệ thống** → **Quyền riêng tư & Bảo mật** → cuộn xuống phần **Bảo mật** → nhấp **Vẫn Mở** → xác nhận bằng cách nhấp **Vẫn Mở** trong hộp thoại.

-macOS Quyền riêng tư & Bảo mật — Vẫn Mở +macOS Quyền riêng tư & Bảo mật — Vẫn Mở

Sau bước này, `picoclaw-launcher` sẽ mở bình thường trong các lần khởi chạy tiếp theo. @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**Bắt đầu:** @@ -307,6 +307,7 @@ Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Ch Để biết tài liệu TUI chi tiết, xem [docs.picoclaw.io](https://docs.picoclaw.io). + ### 📱 Android Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw. @@ -317,10 +318,10 @@ Xem trước: - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot provides a standard Linux filesystem Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu hình. -PicoClaw on Termux +PicoClaw on Termux Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON. @@ -450,7 +451,7 @@ PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử d } ``` -Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md). +Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](../guides/providers.vi.md). @@ -460,28 +461,28 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin: | Channel | Thiết lập | Protocol | Tài liệu | |---------|-----------|----------|----------| -| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](docs/channels/telegram/README.vi.md) | -| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) | -| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) | -| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) | -| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) | -| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) | -| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) | -| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) | -| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) | -| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) | -| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](docs/channels/wecom/README.md) | -| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) | -| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) | -| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) | +| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](../channels/telegram/README.vi.md) | +| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](../channels/discord/README.vi.md) | +| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](../guides/chat-apps.vi.md#whatsapp) | +| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](../guides/chat-apps.vi.md#weixin) | +| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](../channels/qq/README.vi.md) | +| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](../channels/slack/README.vi.md) | +| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](../channels/matrix/README.vi.md) | +| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](../channels/dingtalk/README.vi.md) | +| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](../channels/feishu/README.vi.md) | +| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](../channels/line/README.vi.md) | +| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](../channels/wecom/README.vi.md) | +| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](../guides/chat-apps.vi.md#irc) | +| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](../channels/onebot/README.vi.md) | +| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](../channels/maixcam/README.vi.md) | | **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn | | **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn | > Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung. -> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết. +> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](../guides/configuration.vi.md#mức-log-của-gateway) để biết thêm chi tiết. -Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md). +Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](../guides/chat-apps.vi.md). ## 🔧 Tools @@ -501,7 +502,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C ### ⚙️ Các Tools Khác -PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](docs/vi/tools_configuration.md) để biết chi tiết. +PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](../reference/tools_configuration.vi.md) để biết chi tiết. ## 🎯 Skills @@ -531,7 +532,7 @@ Thêm vào `config.json` của bạn: } ``` -Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool). +Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](../reference/tools_configuration.vi.md#skills-tool). ## 🔗 MCP (Model Context Protocol) @@ -554,9 +555,9 @@ PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nố } ``` -Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool). +Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](../reference/tools_configuration.vi.md#mcp-tool). -## ClawdChat Tham gia Mạng xã hội Agent +## ClawdChat Tham gia Mạng xã hội Agent Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn duy nhất qua CLI hoặc bất kỳ Ứng dụng Chat nào đã tích hợp. @@ -597,23 +598,23 @@ PicoClaw hỗ trợ nhắc nhở đã lên lịch và tác vụ định kỳ th | Chủ đề | Mô tả | |--------|-------| -| [Docker & Khởi động Nhanh](docs/vi/docker.md) | Thiết lập Docker Compose, chế độ Launcher/Agent | -| [Ứng dụng Chat](docs/vi/chat-apps.md) | Hướng dẫn thiết lập 17+ Channel | -| [Cấu hình](docs/vi/configuration.md) | Biến môi trường, bố cục workspace, sandbox bảo mật | -| [Providers & Models](docs/vi/providers.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list | -| [Spawn & Tác vụ Bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ | -| [Hooks](docs/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook | -| [Steering](docs/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | -| [SubTurn](docs/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | -| [Khắc phục sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp | -| [Cấu hình Tools](docs/vi/tools_configuration.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills | -| [Tương thích Phần cứng](docs/vi/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu | +| [Docker & Khởi động Nhanh](../guides/docker.vi.md) | Thiết lập Docker Compose, chế độ Launcher/Agent | +| [Ứng dụng Chat](../guides/chat-apps.vi.md) | Hướng dẫn thiết lập 17+ Channel | +| [Cấu hình](../guides/configuration.vi.md) | Biến môi trường, bố cục workspace, sandbox bảo mật | +| [Providers & Models](../guides/providers.vi.md) | 30+ Provider LLM, định tuyến mô hình, cấu hình model_list | +| [Spawn & Tác vụ Bất đồng bộ](../guides/spawn-tasks.vi.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ | +| [Hooks](../architecture/hooks/README.md) | Hệ thống hook hướng sự kiện: observer, interceptor, approval hook | +| [Steering](../architecture/steering.md) | Chèn tin nhắn vào vòng lặp agent đang chạy | +| [SubTurn](../architecture/subturn.md) | Điều phối subagent, kiểm soát đồng thời, vòng đời | +| [Khắc phục sự cố](../operations/troubleshooting.vi.md) | Các vấn đề thường gặp và giải pháp | +| [Cấu hình Tools](../reference/tools_configuration.vi.md) | Bật/tắt từng tool, chính sách exec, MCP, Skills | +| [Tương thích Phần cứng](../guides/hardware-compatibility.vi.md) | Các board đã kiểm tra, yêu cầu tối thiểu | ## 🤝 Đóng góp & Lộ trình PR luôn được chào đón! Codebase được thiết kế nhỏ gọn và dễ đọc. -Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](CONTRIBUTING.md) để biết hướng dẫn. +Xem [Lộ trình Cộng đồng](https://github.com/sipeed/picoclaw/issues/988) và [CONTRIBUTING.md](../../CONTRIBUTING.md) để biết hướng dẫn. Nhóm nhà phát triển đang được xây dựng, tham gia sau khi PR đầu tiên của bạn được merge! @@ -622,4 +623,4 @@ Nhóm Người dùng: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/README.zh.md b/docs/project/README.zh.md similarity index 82% rename from README.zh.md rename to docs/project/README.zh.md index 1a0659e22..a4fc892bd 100644 --- a/README.zh.md +++ b/docs/project/README.zh.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -14,11 +14,11 @@ Wiki
Twitter - + Discord

-**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md) +**中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | [Malay](README.ms.md) | [English](../../README.md)
@@ -34,12 +34,12 @@

- +

- +

@@ -71,7 +71,7 @@ 2026-02-26 🎉 PicoClaw 仅 17 天突破 **20K Stars**!频道自动编排和能力接口上线。 -2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](ROADMAP.md) 正式发布。 +2026-02-16 🎉 PicoClaw 一周内突破 12K Stars!社区维护者角色和 [路线图](../../ROADMAP.md) 正式发布。 2026-02-13 🎉 PicoClaw 4 天内突破 5000 Stars!项目路线图和开发者群组筹建中。 @@ -108,14 +108,14 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入 | **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | | **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | -PicoClaw +PicoClaw -> 📋 **[硬件兼容列表](docs/zh/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR! +> 📋 **[硬件兼容列表](../guides/hardware-compatibility.zh.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!

-PicoClaw Hardware Compatibility +PicoClaw Hardware Compatibility

## 🦾 演示 @@ -129,9 +129,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入

🔎 网络搜索与学习

-

-

-

+

+

+

开发 • 部署 • 扩展 @@ -220,7 +220,7 @@ picoclaw-launcher > ```

-WebUI Launcher +WebUI Launcher

**开始使用:** @@ -274,7 +274,7 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联 **第一步:** 双击 `picoclaw-launcher`,会出现安全警告:

-macOS Gatekeeper 警告 +macOS Gatekeeper 警告

> *"picoclaw-launcher" 无法打开 — Apple 无法验证 "picoclaw-launcher" 不含可能损害 Mac 或危及隐私的恶意软件。* @@ -282,7 +282,7 @@ macOS 可能会在首次启动时拦截 `picoclaw-launcher`,因为它从互联 **第二步:** 打开**系统设置** → **隐私与安全性** → 向下滚动找到**安全性**部分 → 点击**仍要打开** → 在弹窗中再次点击**打开**。

-macOS 隐私与安全性 — 仍要打开 +macOS 隐私与安全性 — 仍要打开

完成这一次操作后,后续启动 `picoclaw-launcher` 将不再弹出警告。 @@ -298,7 +298,7 @@ picoclaw-launcher-tui ```

-TUI Launcher +TUI Launcher

**开始使用:** @@ -307,6 +307,7 @@ picoclaw-launcher-tui 详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。 + ### 📱 Android 让你十年前的旧手机焕发新生!将它变成你的 AI 助手。 @@ -317,10 +318,10 @@ picoclaw-launcher-tui - - - - + + + +
@@ -344,7 +345,7 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布 然后跟随下面的"Terminal Launcher"章节继续配置。 -PicoClaw on Termux +PicoClaw on Termux 对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。 @@ -450,7 +451,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模 } ``` -完整 Provider 配置详情请参阅 [Providers & Models](docs/zh/providers.md)。 +完整 Provider 配置详情请参阅 [Providers & Models](../guides/providers.zh.md)。 @@ -460,29 +461,29 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模 | Channel | 配置难度 | 协议 | 文档 | |---------|----------|------|------| -| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) | -| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) | -| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) | -| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) | -| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) | -| **Slack** | 简单(bot + app token) | Socket Mode | [指南](docs/channels/slack/README.zh.md) | -| **Matrix** | 中等(homeserver + token) | Sync API | [指南](docs/channels/matrix/README.zh.md) | -| **钉钉** | 中等(client credentials) | Stream | [指南](docs/channels/dingtalk/README.zh.md) | -| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) | -| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) | -| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](docs/channels/wecom/README.zh.md) | -| **VK** | 简单(群组 token) | Long Poll | [指南](docs/channels/vk/README.md) | -| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) | -| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) | -| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) | +| **Telegram** | 简单(bot token) | 长轮询 | [指南](../channels/telegram/README.zh.md) | +| **Discord** | 简单(bot token + intents) | WebSocket | [指南](../channels/discord/README.zh.md) | +| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](../guides/chat-apps.zh.md#whatsapp) | +| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](../guides/chat-apps.zh.md#weixin) | +| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](../channels/qq/README.zh.md) | +| **Slack** | 简单(bot + app token) | Socket Mode | [指南](../channels/slack/README.zh.md) | +| **Matrix** | 中等(homeserver + token) | Sync API | [指南](../channels/matrix/README.zh.md) | +| **钉钉** | 中等(client credentials) | Stream | [指南](../channels/dingtalk/README.zh.md) | +| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](../channels/feishu/README.zh.md) | +| **LINE** | 中等(credentials + webhook) | Webhook | [指南](../channels/line/README.zh.md) | +| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](../channels/wecom/README.zh.md) | +| **VK** | 简单(群组 token) | Long Poll | [指南](../channels/vk/README.md) | +| **IRC** | 中等(server + nick) | IRC 协议 | [指南](../guides/chat-apps.zh.md#irc) | +| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](../channels/onebot/README.zh.md) | +| **MaixCam** | 简单(启用即可) | TCP socket | [指南](../channels/maixcam/README.zh.md) | | **Pico** | 简单(启用即可) | 原生协议 | 内置 | | **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 | > 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。 -> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。 +> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](../guides/configuration.zh.md#gateway-日志等级)。 -详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。 +详细 Channel 配置说明请参阅 [聊天应用配置](../guides/chat-apps.zh.md)。 ## 🔧 Tools @@ -502,7 +503,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置: ### ⚙️ 其他工具 -PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](docs/zh/tools_configuration.md)。 +PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](../reference/tools_configuration.zh.md)。 ## 🎯 Skills @@ -539,7 +540,7 @@ picoclaw skills install `tools.skills.github.*` 已废弃,请改用 `tools.skills.registries.github.*`。 -更多详情请参阅 [工具配置 - Skills](docs/zh/tools_configuration.md#skills-tool)。 +更多详情请参阅 [工具配置 - Skills](../reference/tools_configuration.zh.md#skills-tool)。 ## 🔗 MCP (Model Context Protocol) @@ -562,9 +563,9 @@ PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 M } ``` -完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](docs/zh/tools_configuration.md#mcp-tool)。 +完整 MCP 配置(stdio、SSE、HTTP 传输、Tool Discovery)请参阅 [工具配置 - MCP](../reference/tools_configuration.zh.md#mcp-tool)。 -## ClawdChat 加入 Agent 社交网络 +## ClawdChat 加入 Agent 社交网络 通过 CLI 或任何已集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 @@ -605,23 +606,23 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: | 主题 | 说明 | |------|------| -| 🐳 [Docker 与快速开始](docs/zh/docker.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | -| 💬 [聊天应用配置](docs/zh/chat-apps.md) | 全部 17+ Channel 配置指南 | -| ⚙️ [配置指南](docs/zh/configuration.md) | 环境变量、工作区布局、安全沙箱 | -| 🔌 [提供商与模型配置](docs/zh/providers.md) | 30+ LLM Provider、模型路由、model_list 配置 | -| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | -| 🪝 [Hook 系统](docs/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | -| 🎯 [Steering](docs/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | -| 🔀 [SubTurn](docs/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | -| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 | -| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略、MCP、Skills | -| 📋 [硬件兼容列表](docs/zh/hardware-compatibility.md) | 已测试板卡、最低要求 | +| 🐳 [Docker 与快速开始](../guides/docker.zh.md) | Docker Compose 配置、Launcher/Agent 模式、快速开始 | +| 💬 [聊天应用配置](../guides/chat-apps.zh.md) | 全部 17+ Channel 配置指南 | +| ⚙️ [配置指南](../guides/configuration.zh.md) | 环境变量、工作区布局、安全沙箱 | +| 🔌 [提供商与模型配置](../guides/providers.zh.md) | 30+ LLM Provider、模型路由、model_list 配置 | +| 🔄 [异步任务与 Spawn](../guides/spawn-tasks.zh.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 | +| 🪝 [Hook 系统](../architecture/hooks/README.zh.md) | 事件驱动 Hook:观察者、拦截器、审批 Hook | +| 🎯 [Steering](../architecture/steering.md) | 在工具调用间向运行中的 Agent 注入消息 | +| 🔀 [SubTurn](../architecture/subturn.md) | 子 Agent 协调、并发控制、生命周期管理 | +| 🐛 [疑难解答](../operations/troubleshooting.zh.md) | 常见问题与解决方案 | +| 🔧 [工具配置](../reference/tools_configuration.zh.md) | 工具启用/禁用、执行策略、MCP、Skills | +| 📋 [硬件兼容列表](../guides/hardware-compatibility.zh.md) | 已测试板卡、最低要求 | ## 🤝 贡献与路线图 欢迎提交 PR!代码库刻意保持小巧和可读。🤗 -查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](CONTRIBUTING.md)。 +查看完整的 [社区路线图](https://github.com/sipeed/picoclaw/issues/988) 和 [CONTRIBUTING.md](../../CONTRIBUTING.md)。 开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 @@ -630,4 +631,4 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: Discord: WeChat: -WeChat group QR code +WeChat group QR code diff --git a/docs/reference/README.md b/docs/reference/README.md new file mode 100644 index 000000000..2e0f53cf7 --- /dev/null +++ b/docs/reference/README.md @@ -0,0 +1,9 @@ +# Reference + +Reference docs for precise configuration, runtime behavior, and tool semantics. + +- [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills. +- [MCP Server CLI](mcp-cli.md): add, list, test, edit, and remove MCP server entries from the command line. +- [Scheduled Tasks and Cron Jobs](cron.md): schedule types, delivery modes, command gates, and storage. +- [Config Schema Versioning Guide](config-versioning.md): config schema migration and compatibility notes. +- [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers. diff --git a/docs/config-versioning.md b/docs/reference/config-versioning.md similarity index 100% rename from docs/config-versioning.md rename to docs/reference/config-versioning.md diff --git a/docs/cron.md b/docs/reference/cron.md similarity index 100% rename from docs/cron.md rename to docs/reference/cron.md diff --git a/docs/reference/mcp-cli.md b/docs/reference/mcp-cli.md new file mode 100644 index 000000000..18b2b4c1c --- /dev/null +++ b/docs/reference/mcp-cli.md @@ -0,0 +1,361 @@ +# MCP Server CLI + +> Back to [README](../README.md) + +PicoClaw includes an `mcp` CLI command group for managing MCP server entries in `config.json`. + +This CLI acts as a **configuration manager**: + +- it adds, updates, removes, and validates entries under `tools.mcp.servers` +- it does **not** keep MCP servers running itself +- the gateway / host still starts the configured servers when MCP is enabled + +## Where It Writes + +The CLI updates the same config file used by the rest of PicoClaw: + +- `PICOCLAW_CONFIG` if set +- otherwise `~/.picoclaw/config.json` + +When the CLI writes the file, it: + +- saves atomically +- preserves the standard 2-space JSON formatting used by PicoClaw +- validates the generated JSON before writing + +Behavior notes: + +- `picoclaw mcp add ...` enables `tools.mcp.enabled` +- removing the last server with `picoclaw mcp remove ...` disables `tools.mcp.enabled` + +## Quick Start + +Add a stdio server via `npx`: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +``` + +Add a stdio server with environment variables saved in config: + +```bash +picoclaw mcp add github --env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx -- npx -y @modelcontextprotocol/server-github +``` + +Add a stdio server using an env file for secrets: + +```bash +picoclaw mcp add github --env-file .env.github -- npx -y @modelcontextprotocol/server-github +``` + +Add a remote HTTP server: + +```bash +picoclaw mcp add context7 --transport http https://mcp.context7.com/mcp +``` + +Add a remote HTTP server with auth header, even with flags after the URL: + +```bash +picoclaw mcp add apify "https://mcp.apify.com/" -t http --header "Authorization: Bearer OMITTED" +``` + +Add a stdio server using an explicit command separator: + +```bash +picoclaw mcp add --transport stdio --env AIRTABLE_API_KEY=YOUR_KEY airtable -- npx -y airtable-mcp-server +``` + +Inspect the configured entries: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +Inspect one server's full details and its exposed tools: + +```bash +picoclaw mcp show filesystem +``` + +Probe a single server entry: + +```bash +picoclaw mcp test filesystem +``` + +Open the raw config for advanced editing: + +```bash +picoclaw mcp edit +``` + +## Command Summary + +| Command | Purpose | +|---------|---------| +| `picoclaw mcp add [flags] [args...]` | Add or update an MCP server entry | +| `picoclaw mcp remove ` | Remove a server entry from config | +| `picoclaw mcp list` | List configured MCP servers | +| `picoclaw mcp show ` | Show full details and tools for one server | +| `picoclaw mcp test ` | Try connecting to one configured server | +| `picoclaw mcp edit` | Open `config.json` in `$EDITOR` | + +## `picoclaw mcp add` + +Syntax: + +```bash +picoclaw mcp add [flags] [args...] +``` + +Supported flags: + +| Flag | Meaning | +|------|---------| +| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. Values are saved to config. | +| `--env-file` | Attach an env file path to a stdio server. Recommended for secrets you do not want stored inline in `config.json`. | +| `--header`, `-H` | Add an HTTP header in `Name: Value` or `Name=Value` format. Repeatable. | +| `--transport`, `-t` | Transport type: `stdio` (default), `http`, or `sse`. | +| `--force`, `-f` | Overwrite an existing server entry without confirmation. | +| `--deferred` | Mark the server as deferred: tools are hidden and discoverable on demand. | +| `--no-deferred` | Mark the server as non-deferred: tools are always loaded into context. | + +When neither `--deferred` nor `--no-deferred` is passed, the `deferred` field is omitted from the stored config and the global `discovery.enabled` value applies at runtime. + +Supported forms: + +```bash +picoclaw mcp add [flags] [args...] +picoclaw mcp add [flags] -- [args...] +``` + +Parsing behavior: + +- CLI flags can appear before the name, between the name and target, or after the URL for remote transports +- for `stdio`, the most robust form is `-- [args...]` +- use the `--` separator when the stdio command itself has arguments that may look like PicoClaw CLI flags +- without `--`, PicoClaw treats the first two non-flag tokens as `` and `` + +Secret handling: + +- `--env KEY=value` stores the resolved value directly in `config.json` +- use `--env-file` instead when the value is sensitive and should stay outside the main config file + +Example: + +```bash +picoclaw mcp add sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +This stores: + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"] + } + } + } + } +} +``` + +Adding the same server with `--deferred` stores the extra field: + +```bash +picoclaw mcp add --deferred sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +```json +{ + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"], + "deferred": true + } +} +``` + +### Add Command Rules + +For `stdio`: + +- `` is treated as the command +- `[args...]` are stored in `args` +- `--env` is supported +- `--env-file` is supported and stored in `env_file` +- `--header` is rejected +- `-- [args...]` is supported and recommended for unambiguous parsing + +For `http` / `sse`: + +- `` must be a valid URL +- extra command args are rejected +- `--env` is rejected +- `--env-file` is rejected +- `--header` is supported and stored in `headers` + +Overwrite behavior: + +- if `` already exists, PicoClaw asks for confirmation +- use `--force` to skip the prompt + +Local path validation: + +- if the command looks like a local path such as `./server.py` or `/opt/mcp/server` +- PicoClaw checks that the file exists +- on non-Windows platforms, it also checks that the file is executable + +Clear URL/transport error: + +- if the target looks like `https://...` but transport is still `stdio`, PicoClaw returns an explicit error telling you to use `--transport http` or `--transport sse` + +## `picoclaw mcp remove` + +Syntax: + +```bash +picoclaw mcp remove +``` + +This removes the named entry from `tools.mcp.servers`. + +If the removed server was the last configured MCP server, PicoClaw also disables `tools.mcp.enabled`. + +## `picoclaw mcp list` + +Syntax: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +On wide terminals the output is a styled box (same look as `mcp show`). On narrow terminals or when stdout is not a TTY, a plain ASCII table is printed instead. + +Output fields: + +| Field | Meaning | +|-------|---------| +| `Name` | Server key inside `tools.mcp.servers` | +| `Type` | Effective transport: `stdio`, `http`, or `sse` | +| `Command` / `Target` | Stored command line for stdio servers, or URL for remote servers | +| `Status` | `enabled` / `disabled` by default; with `--status`: `ok (N tools)` or `error` | +| `Deferred` | `deferred` if the per-server override is `true`; `eager` if `false`; omitted if not set | + +Notes: + +- without `--status`, PicoClaw prints configuration state only +- with `--status`, PicoClaw tries to connect to each enabled server and reports `ok (N tools)` or `error` +- to see the full list of tools a server exposes, use `picoclaw mcp show ` + +## `picoclaw mcp show` + +Syntax: + +```bash +picoclaw mcp show +picoclaw mcp show --timeout 15s +``` + +This connects to the named server and prints: + +- server metadata: name, transport type, target, enabled state, deferred override, env var names, env file, header names +- every tool the server exposes, with its name, description, and parameters (name, type, required/optional, description) + +On wide terminals the output is a styled box matching the `mcp list` look. On narrow terminals or non-TTY stdout, plain text is printed instead. + +Example output (wide terminal): + +``` +╭──────────────────────────────────────────────────────────╮ +│ ⬡ filesystem │ +│ │ +│ Type stdio │ +│ Target npx -y @modelcontextprotocol/server-fs /tmp │ +│ Enabled yes │ +│ Deferred no │ +│ │ +│ Tools (3) │ +│ │ +│ read_file [1/3] │ +│ Read the complete contents of a file from the disk │ +│ │ +│ path required │ +│ Path to the file to read │ +│ ──────────────────────────────────────────────────────── │ +│ ... │ +╰──────────────────────────────────────────────────────────╯ +``` + +Flags: + +| Flag | Default | Meaning | +|------|---------|---------| +| `--timeout` | `10s` | Connection timeout | + +Notes: + +- if the server is disabled in config, `mcp show` prints the metadata only and skips tool discovery +- `mcp show` always connects live to fetch the tool list; use `mcp test` if you only need a reachability check + +## `picoclaw mcp test` + +Syntax: + +```bash +picoclaw mcp test +``` + +This performs a direct connection test for one configured entry and prints the number of discovered tools when successful. + +It is useful when: + +- you want to verify a newly added server before starting the gateway +- you want to debug one server without probing the whole list +- the entry is currently disabled in config but you still want to validate its definition + +## `picoclaw mcp edit` + +Syntax: + +```bash +picoclaw mcp edit +``` + +This opens the config file in the editor pointed to by `$EDITOR`. + +Use it when you need to configure MCP fields that are not exposed directly by `picoclaw mcp add`. + +If `$EDITOR` is not set, the command fails with an explicit error. + +## Recommended Workflow + +For common cases: + +1. Add the server with `picoclaw mcp add` (include `--deferred` if you want tools hidden by default). +2. Verify connectivity and inspect the exposed tools with `picoclaw mcp show `. +3. Check all servers at a glance with `picoclaw mcp list --status`. +4. Start PicoClaw normally so the configured MCP server is loaded by the host. + +For advanced cases: + +1. Add the base entry with `picoclaw mcp add`. +2. Run `picoclaw mcp edit` to fill in fields that are not exposed as CLI flags. +3. Run `picoclaw mcp show ` to confirm the final configuration and tool list. + +## Related Docs + +- [Tools Configuration](tools_configuration.md#mcp-tool): MCP config structure, transports, discovery, and examples +- [README](../README.md): high-level overview diff --git a/docs/rate-limiting.md b/docs/reference/rate-limiting.md similarity index 95% rename from docs/rate-limiting.md rename to docs/reference/rate-limiting.md index b54c757f8..d491c9c56 100644 --- a/docs/rate-limiting.md +++ b/docs/reference/rate-limiting.md @@ -39,20 +39,23 @@ Set `rpm` on any model in `model_list`: ```yaml model_list: - model_name: gpt-4o-free - model: openai/gpt-4o + provider: openai + model: gpt-4o api_base: https://api.openai.com/v1 rpm: 3 # max 3 requests per minute api_keys: - sk-... - model_name: claude-haiku - model: anthropic/claude-haiku-4-5 + provider: anthropic + model: claude-haiku-4-5 rpm: 60 # 60 rpm (Anthropic free tier) api_keys: - sk-ant-... - model_name: local-llm - model: openai/llama3 + provider: ollama + model: llama3 api_base: http://localhost:11434/v1 # no rpm → unrestricted ``` @@ -68,7 +71,8 @@ When a model has fallbacks configured, each candidate is rate-limited **independ ```yaml model_list: - model_name: gpt4-with-fallback - model: openai/gpt-4o + provider: openai + model: gpt-4o rpm: 5 fallbacks: - gpt-4o-mini # must also be in model_list; its own rpm applies diff --git a/docs/fr/tools_configuration.md b/docs/reference/tools_configuration.fr.md similarity index 99% rename from docs/fr/tools_configuration.md rename to docs/reference/tools_configuration.fr.md index e64217c46..109c9cd6f 100644 --- a/docs/fr/tools_configuration.md +++ b/docs/reference/tools_configuration.fr.md @@ -1,6 +1,6 @@ # 🔧 Configuration des Outils -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) La configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`. @@ -207,6 +207,7 @@ L'outil cron est utilisé pour planifier des tâches périodiques. |------------------------|------|------------|----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Délai d'expiration en minutes, 0 signifie sans limite | + ## Outil MCP L'outil MCP permet l'intégration avec des serveurs Model Context Protocol externes. @@ -362,6 +363,7 @@ Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger } ``` + ## Outil Skills L'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub. diff --git a/docs/ja/tools_configuration.md b/docs/reference/tools_configuration.ja.md similarity index 99% rename from docs/ja/tools_configuration.md rename to docs/reference/tools_configuration.ja.md index a31e58984..a331c869e 100644 --- a/docs/ja/tools_configuration.md +++ b/docs/reference/tools_configuration.ja.md @@ -1,6 +1,6 @@ # 🔧 ツール設定 -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る PicoClaw のツール設定は `config.json` の `tools` フィールドにあります。 @@ -207,6 +207,7 @@ Cron ツールは定期タスクのスケジューリングに使用されます |------------------------|-----|------------|-----------------------------------------| | `exec_timeout_minutes` | int | 5 | 実行タイムアウト(分)、0 は無制限 | + ## MCP ツール MCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。 @@ -362,6 +363,7 @@ MCP ツールは外部の Model Context Protocol サーバーとの統合を可 } ``` + ## Skills ツール Skills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。 diff --git a/docs/tools_configuration.md b/docs/reference/tools_configuration.md similarity index 96% rename from docs/tools_configuration.md rename to docs/reference/tools_configuration.md index b043716ed..810d91ef2 100644 --- a/docs/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -30,7 +30,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. Before tool results are sent to the LLM, PicoClaw can filter sensitive values (API keys, tokens, secrets) from the output. This prevents the LLM from seeing its own credentials. -See [Sensitive Data Filtering](../sensitive_data_filtering.md) for full documentation. +See [Sensitive Data Filtering](../security/sensitive_data_filtering.md) for full documentation. | Config | Type | Default | Description | |--------|------|---------|-------------| @@ -258,6 +258,17 @@ For schedule types, execution modes (`deliver`, agent turn, and command jobs), p The MCP tool enables integration with external Model Context Protocol servers. +If you prefer not to edit JSON manually, PicoClaw also provides an MCP configuration manager CLI: + +- `picoclaw mcp add` — add or update a server (supports `--deferred` / `--no-deferred`) +- `picoclaw mcp list` — list all configured servers with status and deferred state +- `picoclaw mcp show ` — show full details and the tool list for one server +- `picoclaw mcp test ` — connectivity check for one server +- `picoclaw mcp remove ` — remove a server entry +- `picoclaw mcp edit` — open `config.json` in `$EDITOR` for advanced edits + +These commands manage the same `tools.mcp.servers` section documented below. See [MCP Server CLI](mcp-cli.md) for command syntax, examples, and behavior details. + ### Tool Discovery (Lazy Loading) When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window diff --git a/docs/pt-br/tools_configuration.md b/docs/reference/tools_configuration.pt-br.md similarity index 99% rename from docs/pt-br/tools_configuration.md rename to docs/reference/tools_configuration.pt-br.md index 0eea7209a..3dae0f908 100644 --- a/docs/pt-br/tools_configuration.md +++ b/docs/reference/tools_configuration.pt-br.md @@ -1,6 +1,6 @@ # 🔧 Configuração de Ferramentas -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) A configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`. @@ -207,6 +207,7 @@ A ferramenta cron é usada para agendar tarefas periódicas. |------------------------|------|--------|-----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Tempo limite de execução em minutos, 0 significa sem limite | + ## Ferramenta MCP A ferramenta MCP permite a integração com servidores Model Context Protocol externos. @@ -362,6 +363,7 @@ Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa } ``` + ## Ferramenta Skills A ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub. diff --git a/docs/vi/tools_configuration.md b/docs/reference/tools_configuration.vi.md similarity index 99% rename from docs/vi/tools_configuration.md rename to docs/reference/tools_configuration.vi.md index 14abbfba7..7d65ca377 100644 --- a/docs/vi/tools_configuration.md +++ b/docs/reference/tools_configuration.vi.md @@ -1,6 +1,6 @@ # 🔧 Cấu Hình Công Cụ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) Cấu hình công cụ của PicoClaw nằm trong trường `tools` của `config.json`. @@ -207,6 +207,7 @@ Công cụ cron được sử dụng để lên lịch các tác vụ định k |--------------------------|------|----------|-----------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Thời gian chờ thực thi tính bằng phút, 0 nghĩa là không giới hạn | + ## Công cụ MCP Công cụ MCP cho phép tích hợp với các máy chủ Model Context Protocol bên ngoài. @@ -362,6 +363,7 @@ Thay vì tải tất cả các công cụ, LLM được cung cấp một công c } ``` + ## Công cụ Skills Công cụ skills cấu hình khám phá và cài đặt kỹ năng thông qua các registry như ClawHub. diff --git a/docs/zh/tools_configuration.md b/docs/reference/tools_configuration.zh.md similarity index 99% rename from docs/zh/tools_configuration.md rename to docs/reference/tools_configuration.zh.md index 9b3bfe4cf..3937a6254 100644 --- a/docs/zh/tools_configuration.md +++ b/docs/reference/tools_configuration.zh.md @@ -1,6 +1,6 @@ # 🔧 工具配置 -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 @@ -32,7 +32,7 @@ PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。 在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。 -详细说明请参阅[敏感数据过滤](../sensitive_data_filtering.md)。 +详细说明请参阅[敏感数据过滤](../security/sensitive_data_filtering.zh.md)。 | 配置项 | 类型 | 默认值 | 描述 | |--------|------|--------|------| @@ -234,6 +234,7 @@ Cron 工具用于调度周期性任务。 | `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 | | `allow_command` | bool | false | 允许 cron 任务执行 shell 命令 | + ## MCP 工具 MCP 工具支持与外部 Model Context Protocol 服务器集成。 @@ -389,6 +390,7 @@ LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用 } ``` + ## Skills 工具 Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。 diff --git a/docs/fr/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.fr.md similarity index 99% rename from docs/fr/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.fr.md index 6cadf5238..8550c94e3 100644 --- a/docs/fr/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Guide d'authentification et d'intégration Antigravity diff --git a/docs/ja/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.ja.md similarity index 99% rename from docs/ja/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.ja.md index b55e4ab1b..e5ba91f8e 100644 --- a/docs/ja/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # Antigravity 認証・統合ガイド diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.md similarity index 100% rename from docs/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.md diff --git a/docs/pt-br/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.pt-br.md similarity index 99% rename from docs/pt-br/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.pt-br.md index d243783cb..626dc7433 100644 --- a/docs/pt-br/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Guia de Autenticação e Integração do Antigravity diff --git a/docs/vi/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.vi.md similarity index 99% rename from docs/vi/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.vi.md index 783dc5181..0800ce0f2 100644 --- a/docs/vi/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Hướng dẫn Xác thực và Tích hợp Antigravity diff --git a/docs/zh/ANTIGRAVITY_AUTH.md b/docs/security/ANTIGRAVITY_AUTH.zh.md similarity index 99% rename from docs/zh/ANTIGRAVITY_AUTH.md rename to docs/security/ANTIGRAVITY_AUTH.zh.md index db7c81dea..5ae5c8afe 100644 --- a/docs/zh/ANTIGRAVITY_AUTH.md +++ b/docs/security/ANTIGRAVITY_AUTH.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # Antigravity 认证与集成指南 diff --git a/docs/security/README.md b/docs/security/README.md new file mode 100644 index 000000000..7bd42da18 --- /dev/null +++ b/docs/security/README.md @@ -0,0 +1,8 @@ +# Security + +Security-focused docs covering configuration, secrets handling, and provider auth. + +- [Security Configuration](security_configuration.md): security-related config knobs and hardening guidance. +- [Sensitive Data Filtering](sensitive_data_filtering.md): filtering secrets from tool output before model use. +- [Credential Encryption](credential_encryption.md): encrypting stored API keys and credentials. +- [Antigravity Authentication & Integration Guide](ANTIGRAVITY_AUTH.md): auth flow and integration notes for the Antigravity provider. diff --git a/docs/fr/credential_encryption.md b/docs/security/credential_encryption.fr.md similarity index 99% rename from docs/fr/credential_encryption.md rename to docs/security/credential_encryption.fr.md index eec765039..67e2ed123 100644 --- a/docs/fr/credential_encryption.md +++ b/docs/security/credential_encryption.fr.md @@ -1,4 +1,4 @@ -> Retour au [README](../../README.fr.md) +> Retour au [README](../project/README.fr.md) # Chiffrement des identifiants diff --git a/docs/ja/credential_encryption.md b/docs/security/credential_encryption.ja.md similarity index 99% rename from docs/ja/credential_encryption.md rename to docs/security/credential_encryption.ja.md index ea74b65d2..9eeba98b4 100644 --- a/docs/ja/credential_encryption.md +++ b/docs/security/credential_encryption.ja.md @@ -1,4 +1,4 @@ -> [README](../../README.ja.md) に戻る +> [README](../project/README.ja.md) に戻る # クレデンシャル暗号化 diff --git a/docs/credential_encryption.md b/docs/security/credential_encryption.md similarity index 100% rename from docs/credential_encryption.md rename to docs/security/credential_encryption.md diff --git a/docs/pt-br/credential_encryption.md b/docs/security/credential_encryption.pt-br.md similarity index 99% rename from docs/pt-br/credential_encryption.md rename to docs/security/credential_encryption.pt-br.md index 59a31e438..d4a84be8e 100644 --- a/docs/pt-br/credential_encryption.md +++ b/docs/security/credential_encryption.pt-br.md @@ -1,4 +1,4 @@ -> Voltar ao [README](../../README.pt-br.md) +> Voltar ao [README](../project/README.pt-br.md) # Criptografia de Credenciais diff --git a/docs/vi/credential_encryption.md b/docs/security/credential_encryption.vi.md similarity index 99% rename from docs/vi/credential_encryption.md rename to docs/security/credential_encryption.vi.md index 9ba24588b..38d568b94 100644 --- a/docs/vi/credential_encryption.md +++ b/docs/security/credential_encryption.vi.md @@ -1,4 +1,4 @@ -> Quay lại [README](../../README.vi.md) +> Quay lại [README](../project/README.vi.md) # Mã hóa Thông tin Xác thực diff --git a/docs/zh/credential_encryption.md b/docs/security/credential_encryption.zh.md similarity index 99% rename from docs/zh/credential_encryption.md rename to docs/security/credential_encryption.zh.md index 2105e4307..5083eee18 100644 --- a/docs/zh/credential_encryption.md +++ b/docs/security/credential_encryption.zh.md @@ -1,4 +1,4 @@ -> 返回 [README](../../README.zh.md) +> 返回 [README](../project/README.zh.md) # 凭据加密 diff --git a/docs/security_configuration.md b/docs/security/security_configuration.md similarity index 100% rename from docs/security_configuration.md rename to docs/security/security_configuration.md diff --git a/docs/sensitive_data_filtering.md b/docs/security/sensitive_data_filtering.md similarity index 98% rename from docs/sensitive_data_filtering.md rename to docs/security/sensitive_data_filtering.md index 0c10ff01d..e2d9de427 100644 --- a/docs/sensitive_data_filtering.md +++ b/docs/security/sensitive_data_filtering.md @@ -104,4 +104,4 @@ The model is using API key [FILTERED] and Telegram bot [FILTERED] ## Related - [Credential Encryption](./credential_encryption.md) — encrypting API keys in config -- [Tools Configuration](./tools_configuration.md) +- [Tools Configuration](../reference/tools_configuration.md) diff --git a/docs/zh/sensitive_data_filtering.md b/docs/security/sensitive_data_filtering.zh.md similarity index 95% rename from docs/zh/sensitive_data_filtering.md rename to docs/security/sensitive_data_filtering.zh.md index 4382706ed..6ff1acc20 100644 --- a/docs/zh/sensitive_data_filtering.md +++ b/docs/security/sensitive_data_filtering.zh.md @@ -103,5 +103,5 @@ The model is using API key [FILTERED] and Telegram bot [FILTERED] ## 相关文档 -- [凭据加密](../credential_encryption.md) — 配置中 API 密钥的加密 -- [工具配置](../tools_configuration.md) +- [凭据加密](./credential_encryption.zh.md) — 配置中 API 密钥的加密 +- [工具配置](../reference/tools_configuration.zh.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index 096beec78..000000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,43 +0,0 @@ -# Troubleshooting - -## "model ... not found in model_list" or OpenRouter "free is not a valid model ID" - -**Symptom:** You see either: - -- `Error creating provider: model "openrouter/free" not found in model_list` -- OpenRouter returns 400: `"free is not a valid model ID"` - -**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand. - -- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it. -- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing). - -**Fix:** In `~/.picoclaw/config.json` (or your config path): - -1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). -2. That entry’s **model** must be a valid OpenRouter model ID, for example: - - `"openrouter/free"` – auto free-tier - - `"google/gemini-2.0-flash-exp:free"` - - `"meta-llama/llama-3.1-8b-instruct:free"` - -Example snippet: - -```json -{ - "agents": { - "defaults": { - "model_name": "openrouter-free" - } - }, - "model_list": [ - { - "model_name": "openrouter-free", - "model": "openrouter/free", - "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", - "api_base": "https://openrouter.ai/api/v1" - } - ] -} -``` - -Get your key at [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md deleted file mode 100644 index be4d4f5d7..000000000 --- a/docs/zh/troubleshooting.md +++ /dev/null @@ -1,45 +0,0 @@ -# 🐛 疑难解答 - -> 返回 [README](../../README.zh.md) - -## "model ... not found in model_list" 或 OpenRouter "free is not a valid model ID" - -**症状:** 你看到以下任一错误: - -- `Error creating provider: model "openrouter/free" not found in model_list` -- OpenRouter 返回 400:`"free is not a valid model ID"` - -**原因:** `model_list` 条目中的 `model` 字段是发送给 API 的内容。对于 OpenRouter,你必须使用**完整的**模型 ID,而不是简写。 - -- **错误:** `"model": "free"` → OpenRouter 收到 `free` 并拒绝。 -- **正确:** `"model": "openrouter/free"` → OpenRouter 收到 `openrouter/free`(自动免费层路由)。 - -**修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中: - -1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。 -2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID,例如: - - `"openrouter/free"` – 自动免费层 - - `"google/gemini-2.0-flash-exp:free"` - - `"meta-llama/llama-3.1-8b-instruct:free"` - -示例片段: - -```json -{ - "agents": { - "defaults": { - "model_name": "openrouter-free" - } - }, - "model_list": [ - { - "model_name": "openrouter-free", - "model": "openrouter/free", - "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", - "api_base": "https://openrouter.ai/api/v1" - } - ] -} -``` - -在 [OpenRouter Keys](https://openrouter.ai/keys) 获取你的密钥。 diff --git a/go.mod b/go.mod index 0b7076c45..cea8de00e 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,9 @@ require ( 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.5 - github.com/aws/aws-sdk-go-v2/config v1.32.14 - github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 + 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/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 github.com/charmbracelet/lipgloss v1.1.0 @@ -19,36 +19,36 @@ require ( 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-20260217112301-37c66b85d6ab + 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.3 + github.com/larksuite/oapi-sdk-go/v3 v3.5.4 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 - github.com/muesli/termenv v0.16.0 github.com/modelcontextprotocol/go-sdk v1.5.0 + github.com/muesli/termenv v0.16.0 github.com/mymmrac/telego v1.8.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 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.0 + github.com/rs/zerolog v1.35.1 github.com/slack-go/slack v0.17.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 - go.mau.fi/util v0.9.7 + go.mau.fi/util v0.9.8 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 golang.org/x/time v0.15.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.26.4 + maunium.net/go/mautrix v0.27.0 modernc.org/sqlite v1.48.2 rsc.io/qr v0.2.0 ) @@ -56,19 +56,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.8 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect - github.com/aws/smithy-go v1.24.2 // 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/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 @@ -89,9 +89,9 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.34 // indirect + github.com/mattn/go-sqlite3 v1.14.42 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect @@ -107,8 +107,8 @@ require ( go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/text v0.36.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -123,7 +123,7 @@ require ( github.com/github/copilot-sdk/go v0.2.0 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/jsonschema-go v0.4.2 github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -137,8 +137,8 @@ require ( github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.49.0 - golang.org/x/net v0.52.0 + golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 ) diff --git a/go.sum b/go.sum index 97d75c137..7dbff8f9b 100644 --- a/go.sum +++ b/go.sum @@ -23,38 +23,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.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= -github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= -github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +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/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= @@ -138,8 +138,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= -github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8= +github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -183,8 +183,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.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= -github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +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/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= @@ -195,16 +195,16 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mymmrac/telego v1.8.0 h1:EvIprWo9Cn0MHgumvvqNXPAXO1yJj3pu2cdCCeDxbow= github.com/mymmrac/telego v1.8.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -221,8 +221,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= -github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA= @@ -243,8 +243,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= -github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= @@ -295,12 +295,12 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yeongaori/discordgo-fork v0.0.0-20260319072544-e8e546f5d532 h1:gxFHYeUDGziRb0zXYEqBFohC+NJbIW9L0tddaXMWr2o= @@ -312,8 +312,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg= -go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE= +go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8= +go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -336,16 +336,16 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -360,8 +360,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -402,8 +402,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -411,8 +411,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -422,8 +422,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -453,8 +453,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs= -maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M= +maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM= +maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/pkg/agent/adapters/channelmanager.go b/pkg/agent/adapters/channelmanager.go new file mode 100644 index 000000000..8265ef99d --- /dev/null +++ b/pkg/agent/adapters/channelmanager.go @@ -0,0 +1,45 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// channelManagerAdapter wraps *channels.Manager to implement interfaces.ChannelManager. +type channelManagerAdapter struct { + inner *channels.Manager +} + +// NewChannelManager creates an adapter for *channels.Manager. +func NewChannelManager(inner *channels.Manager) interfaces.ChannelManager { + return &channelManagerAdapter{inner: inner} +} + +func (a *channelManagerAdapter) GetChannel(name string) (channels.Channel, bool) { + return a.inner.GetChannel(name) +} + +func (a *channelManagerAdapter) GetEnabledChannels() []string { + return a.inner.GetEnabledChannels() +} + +func (a *channelManagerAdapter) InvokeTypingStop(channel, chatID string) { + a.inner.InvokeTypingStop(channel, chatID) +} + +func (a *channelManagerAdapter) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.SendMessage(ctx, msg) +} + +func (a *channelManagerAdapter) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.SendMedia(ctx, msg) +} + +func (a *channelManagerAdapter) SendPlaceholder(ctx context.Context, channel, chatID string) bool { + return a.inner.SendPlaceholder(ctx, channel, chatID) +} diff --git a/pkg/agent/adapters/messagebus.go b/pkg/agent/adapters/messagebus.go new file mode 100644 index 000000000..ccae7e8bc --- /dev/null +++ b/pkg/agent/adapters/messagebus.go @@ -0,0 +1,36 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" +) + +// messageBusAdapter wraps *bus.MessageBus to implement interfaces.MessageBus. +type messageBusAdapter struct { + inner *bus.MessageBus +} + +// NewMessageBus creates an adapter for *bus.MessageBus. +func NewMessageBus(inner *bus.MessageBus) interfaces.MessageBus { + return &messageBusAdapter{inner: inner} +} + +func (a *messageBusAdapter) PublishInbound(ctx context.Context, msg bus.InboundMessage) error { + return a.inner.PublishInbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.PublishOutbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.PublishOutboundMedia(ctx, msg) +} + +func (a *messageBusAdapter) InboundChan() <-chan bus.InboundMessage { + return a.inner.InboundChan() +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 000000000..2c456dca7 --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,609 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type AgentLoop struct { + // Core dependencies + bus interfaces.MessageBus + cfg *config.Config + registry *AgentRegistry + state *state.Manager + + // Event system (from Incoming) + eventBus *EventBus + hooks *HookManager + + // Runtime state + running atomic.Bool + contextManager ContextManager + fallback *providers.FallbackChain + channelManager interfaces.ChannelManager + mediaStore media.MediaStore + transcriber asr.Transcriber + cmdRegistry *commands.Registry + mcp mcpRuntime + hookRuntime hookRuntime + steering *steeringQueue + pendingSkills sync.Map + mu sync.RWMutex + + // workerSem limits concurrent turn processing workers. + workerSem chan struct{} + + // activeTurnStates tracks active turns per session to prevent duplicates. + activeTurnStates sync.Map + subTurnCounter atomic.Int64 + + turnSeq atomic.Uint64 + activeRequests sync.WaitGroup + + reloadFunc func() error + + providerFactory func(*config.ModelConfig) (providers.LLMProvider, string, error) +} + +// processOptions configures how a message is processed +type processOptions struct { + Dispatch DispatchRequest // Normalized routed request boundary for this turn + SessionKey string // Session identifier for history/context + SessionAliases []string // Compatibility aliases for the session key + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + MessageID string // Current inbound platform message ID + ReplyToMessageID string // Current inbound reply target message ID + SenderID string // Current sender ID for dynamic context + SenderDisplayName string // Current sender display name for dynamic context + UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message + SystemPromptOverride string // Override the default system prompt (Used by SubTurns) + Media []string // media:// refs from inbound message + InitialSteeringMessages []providers.Message // Steering messages from refactor/agent + DefaultResponse string // Response when LLM returns empty + EnableSummary bool // Whether to trigger summarization + SendResponse bool // Whether to send response via bus + AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false + SuppressToolFeedback bool // Whether to suppress inline tool feedback messages + NoHistory bool // If true, don't load session history (for heartbeat) + SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) + InboundContext *bus.InboundContext // Normalized inbound facts for events/hooks + RouteResult *routing.ResolvedRoute // Route decision snapshot for events/hooks + SessionScope *session.SessionScope // Session scope snapshot for events/hooks +} + +type continuationTarget struct { + SessionKey string + Channel string + ChatID string +} + +const ( + defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." + toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." + handledToolResponseSummary = "Requested output delivered via tool attachment." + sessionKeyAgentPrefix = "agent:" + pendingTurnPrefix = "pending-" + metadataKeyMessageKind = "message_kind" + metadataKeyToolCalls = "tool_calls" + messageKindThought = "thought" + messageKindToolFeedback = "tool_feedback" + messageKindToolCalls = "tool_calls" + metadataKeyAccountID = "account_id" + metadataKeyGuildID = "guild_id" + metadataKeyTeamID = "team_id" + metadataKeyReplyToMessage = "reply_to_message_id" + metadataKeyParentPeerKind = "parent_peer_kind" + metadataKeyParentPeerID = "parent_peer_id" +) + +// registerSharedTools registers tools that are shared across all agents (web, message, spawn). + +func (al *AgentLoop) Run(ctx context.Context) error { + al.running.Store(true) + + if err := al.ensureHooksInitialized(ctx); err != nil { + return err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return err + } + + idleTicker := time.NewTicker(100 * time.Millisecond) + defer idleTicker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-idleTicker.C: + if !al.running.Load() { + return nil + } + case msg, ok := <-al.bus.InboundChan(): + if !ok { + return nil + } + + // Resolve the session key for this message + sessionKey, agentID, ok := al.resolveSteeringTarget(msg) + if !ok { + // Non-routable message (e.g., system) — process immediately. + // Note: system messages are processed in the main goroutine, + // so they block the receive loop but guarantee session serialization. + al.processMessageSync(ctx, msg) + continue + } + + // Atomically claim the session key with a unique placeholder sentinel + // to prevent a TOCTOU race where multiple messages for the same session + // pass the Load check before either registers. + // The placeholder ensures GetActiveTurnBySession() never returns nil + // during turn setup. Each placeholder has a unique turnID to prevent + // cross-worker cleanup issues. + placeholder := &turnState{ + turnID: makePendingTurnID(sessionKey, al.turnSeq.Add(1)), + phase: TurnPhaseSetup, + } + if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + // Another turn is already active (or reserved) for this session — enqueue + if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ + Role: "user", + Content: msg.Content, + Media: append([]string(nil), msg.Media...), + }); err != nil { + logger.WarnCF("agent", "Failed to enqueue steering message", + map[string]any{ + "error": err.Error(), + "channel": msg.Channel, + "chat_id": msg.ChatID, + "session_key": sessionKey, + }) + } + continue + } + + // Session claimed — spawn a worker goroutine that acquires a semaphore + // slot. The goroutine is spawned immediately so the main loop keeps + // draining the inbound channel. The goroutine blocks on the semaphore. + go func(m bus.InboundMessage) { + // Acquire semaphore slot (blocks if at capacity) + select { + case al.workerSem <- struct{}{}: + // Got slot, start worker + case <-ctx.Done(): + // Context canceled while waiting for a slot — clean up the + // placeholder to prevent session-level deadlock. + al.activeTurnStates.Delete(sessionKey) + return + } + + // Safety-net cleanup: if the placeholder was never replaced by a real + // turnState (e.g., error before runTurn), delete it here. When runTurn + // completes normally, clearActiveTurn deletes the real turnState and + // this becomes a no-op (the key is already gone). + defer func() { + if actual, ok := al.activeTurnStates.Load(sessionKey); ok { + if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { + // Placeholder still present — runTurn never replaced it. + al.activeTurnStates.Delete(sessionKey) + } + } + }() + + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + logger.ErrorCF("agent", "Worker goroutine panicked", + map[string]any{ + "session_key": sessionKey, + "channel": m.Channel, + "chat_id": m.ChatID, + "panic": fmt.Sprintf("%v", r), + }) + } + }() + defer func() { <-al.workerSem }() // Release slot + + if al.channelManager != nil { + defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID) + } + + al.runTurnWithSteering(ctx, m) + }(msg) + + // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. + // Currently disabled because files are deleted before the LLM can access their content. + // defer func() { + // if al.mediaStore != nil && msg.MediaScope != "" { + // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { + // logger.WarnCF("agent", "Failed to release media", map[string]any{ + // "scope": msg.MediaScope, + // "error": releaseErr.Error(), + // }) + // } + // } + // }() + } + } +} + +// processMessageSync processes a message synchronously (for non-routable/system messages). + +// runTurnWithSteering runs a complete turn for a message and drains its steering queue. + +// maybePublishError publishes an error response unless the error is context.Canceled. +// Returns true if processing should continue (non-cancellation error or no error), +// false if context was canceled and the caller should return. + +// publishResponseOrError publishes the response, or an error message if processing failed. + +func (al *AgentLoop) Stop() { + al.running.Store(false) +} + +// Close releases resources held by agent session stores. Call after Stop. +func (al *AgentLoop) Close() { + mcpManager := al.mcp.takeManager() + + if mcpManager != nil { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": err.Error(), + }) + } + } + + al.GetRegistry().Close() + if al.hooks != nil { + al.hooks.Close() + } + if al.eventBus != nil { + al.eventBus.Close() + } +} + +// MountHook registers an in-process hook on the agent loop. + +// 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 + turnID string + context *TurnContext +} + +// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization. +// It uses a context to allow timeout control from the caller. +// Returns an error if the reload fails or context is canceled. +func (al *AgentLoop) ReloadProviderAndConfig( + ctx context.Context, + provider providers.LLMProvider, + cfg *config.Config, +) error { + // Validate inputs + if provider == nil { + return fmt.Errorf("provider cannot be nil") + } + if cfg == nil { + return fmt.Errorf("config cannot be nil") + } + + // Create new registry with updated config and provider + // Wrap in defer/recover to handle any panics gracefully + var registry *AgentRegistry + var panicErr error + done := make(chan struct{}, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) + panicErr = fmt.Errorf("panic during registry creation: %v", r) + logger.ErrorCF("agent", "Panic during registry creation", + map[string]any{"panic": r}) + } + close(done) + }() + + registry = NewAgentRegistry(cfg, provider) + }() + + // Wait for completion or context cancellation + select { + case <-done: + if registry == nil { + if panicErr != nil { + return fmt.Errorf("registry creation failed: %w", panicErr) + } + return fmt.Errorf("registry creation failed (nil result)") + } + case <-ctx.Done(): + return fmt.Errorf("context canceled during registry creation: %w", ctx.Err()) + } + + // Check context again before proceeding + if err := ctx.Err(); err != nil { + return fmt.Errorf("context canceled after registry creation: %w", err) + } + + // Ensure shared tools are re-registered on the new registry + registerSharedTools(al, cfg, al.bus, registry, provider) + + // Atomically swap the config and registry under write lock + // This ensures readers see a consistent pair + al.mu.Lock() + oldRegistry := al.registry + + // Store new values + al.cfg = cfg + al.registry = registry + + // Also update fallback chain with new config; rebuild rate limiter registry. + newRL := providers.NewRateLimiterRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + newRL.RegisterCandidates(agent.Candidates) + newRL.RegisterCandidates(agent.LightCandidates) + } + } + al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) + + al.mu.Unlock() + + oldMCPManager := al.mcp.reset() + al.hookRuntime.reset(al) + configureHookManagerFromConfig(al.hooks, cfg) + if err := al.ensureHooksInitialized(ctx); err != nil { + logger.WarnCF("agent", "Configured hooks failed to reinitialize after reload", + map[string]any{"error": err.Error()}) + } + if oldMCPManager != nil { + if err := oldMCPManager.Close(); err != nil { + logger.WarnCF("agent", "Failed to close previous MCP manager during reload", + map[string]any{"error": err.Error()}) + } + } + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "MCP failed to reinitialize after reload", + map[string]any{"error": err.Error()}) + } + + // Close old provider after releasing the lock + // This prevents blocking readers while closing + if oldProvider, ok := extractProvider(oldRegistry); ok { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + // Give in-flight requests a moment to complete + // Use a reasonable timeout that balances cleanup vs resource usage + select { + case <-time.After(100 * time.Millisecond): + stateful.Close() + case <-ctx.Done(): + // Context canceled, close immediately but log warning + logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close", + map[string]any{"error": ctx.Err()}) + stateful.Close() + } + } + } + + logger.InfoCF("agent", "Provider and config reloaded successfully", + map[string]any{ + "model": cfg.Agents.Defaults.GetModelName(), + }) + + return nil +} + +// GetRegistry returns the current registry (thread-safe) + +// GetConfig returns the current config (thread-safe) + +// SetMediaStore injects a MediaStore for media lifecycle management. + +// SetTranscriber injects a voice transcriber for agent-level audio transcription. + +// SetReloadFunc sets the callback function for triggering config reload. + +var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) + +// transcribeAudioInMessage resolves audio media refs, transcribes them, and +// replaces audio annotations in msg.Content with the transcribed text. +// Returns the (possibly modified) message and true if audio was transcribed. + +// sendTranscriptionFeedback sends feedback to the user with the result of +// audio transcription if the option is enabled. It uses Manager.SendMessage +// which executes synchronously (rate limiting, splitting, retry) so that +// ordering with the subsequent placeholder is guaranteed. + +// inferMediaType determines the media type ("image", "audio", "video", "file") +// from a filename and MIME content type. + +// RecordLastChannel records the last active channel for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. + +// RecordLastChatID records the last active chat ID for this workspace. +// This uses the atomic state save mechanism to prevent data loss on crash. + +// ProcessHeartbeat processes a heartbeat request without session history. +// Each heartbeat is independent and doesn't accumulate context. + +// runAgentLoop remains the top-level shell that starts a turn and publishes +// any post-turn work. runTurn owns the full turn lifecycle. +func (al *AgentLoop) runAgentLoop( + ctx context.Context, + agent *AgentInstance, + opts processOptions, +) (string, error) { + opts = normalizeProcessOptions(opts) + + // Record last channel for heartbeat notifications (skip internal channels and cli) + if opts.Dispatch.Channel() != "" && + opts.Dispatch.ChatID() != "" && + !constants.IsInternalChannel(opts.Dispatch.Channel()) { + channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID()) + if err := al.RecordLastChannel(channelKey); err != nil { + logger.WarnCF( + "agent", + "Failed to record last channel", + map[string]any{"error": err.Error()}, + ) + } + } + + ensureSessionMetadata( + agent.Sessions, + opts.Dispatch.SessionKey, + opts.Dispatch.SessionScope, + opts.Dispatch.SessionAliases, + ) + + turnScope := al.newTurnEventScope( + agent.ID, + opts.Dispatch.SessionKey, + newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope), + ) + ts := newTurnState(agent, opts, turnScope) + pipeline := NewPipeline(al) + result, err := al.runTurn(ctx, ts, pipeline) + if err != nil { + return "", err + } + if result.status == TurnEndStatusAborted { + return "", nil + } + + for _, followUp := range result.followUps { + if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil { + logger.WarnCF("agent", "Failed to publish follow-up after turn", + map[string]any{ + "turn_id": ts.turnID, + "error": pubErr.Error(), + }) + } + } + + if opts.SendResponse && result.finalContent != "" { + agentID, sessionKey, scope := outboundTurnMetadata( + agent.ID, + opts.Dispatch.SessionKey, + opts.Dispatch.SessionScope, + ) + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: outboundContextFromInbound( + opts.Dispatch.InboundContext, + opts.Dispatch.Channel(), + opts.Dispatch.ChatID(), + opts.Dispatch.ReplyToMessageID(), + ), + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: result.finalContent, + ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey), + }) + } + + if result.finalContent != "" { + responsePreview := utils.Truncate(result.finalContent, 120) + logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), + map[string]any{ + "agent_id": agent.ID, + "session_key": opts.Dispatch.SessionKey, + "iterations": ts.currentIteration(), + "final_length": len(result.finalContent), + }) + } + + return result.finalContent, nil +} + +// selectCandidates returns the model candidates and resolved model name to use +// for a conversation turn. When model routing is configured and the incoming +// message scores below the complexity threshold, it returns the light model +// candidates instead of the primary ones. +// +// The returned (candidates, model) pair is used for all LLM calls within one +// turn — tool follow-up iterations use the same tier as the initial call so +// that a multi-step tool chain doesn't switch models mid-way. + +// resolveContextManager selects the ContextManager implementation based on config. + +// GetStartupInfo returns information about loaded tools and skills for logging. + +// formatMessagesForLog formats messages for logging + +// formatToolsForLog formats tool definitions for logging + +// summarizeSession summarizes the conversation history for a session. +// findNearestUserMessage finds the nearest user message to the given index. +// It searches backward first, then forward if no user message is found. +// retryLLMCall calls the LLM with retry logic. +// summarizeBatch summarizes a batch of messages. +// estimateTokens estimates the number of tokens in a message list. +// Counts Content, ToolCalls arguments, and ToolCallID metadata so that +// tool-heavy conversations are not systematically undercounted. + +// askSideQuestion handles /btw commands by creating an isolated provider instance +// that doesn't share state with the main conversation provider. + +// shallowCloneLLMOptions creates a shallow copy of LLM options map. +// Note: This is a shallow copy - nested maps/slices are shared. + +// hasMediaRefs checks if any message has media references. + +// isolatedSideQuestionProvider creates a separate provider instance for /btw commands +// to avoid sharing state with the main conversation provider. + +// sideQuestionModelConfig resolves the model config for side questions. + +// sideQuestionModelName determines which model name to use for side questions. + +// modelNameFromIdentityKey extracts the model name from an identity key. + +// closeProviderIfStateful closes a provider if it implements StatefulProvider. + +// makePendingTurnID generates a unique turn ID for placeholder turns. +// Format: "pending-{sessionKey}-{sequence}" + +// isNativeSearchProvider reports whether the given LLM provider implements +// NativeSearchCapable and returns true for SupportsNativeSearch. + +// filterClientWebSearch returns a copy of tools with the client-side +// web_search tool removed. Used when native provider search is preferred. + +// Helper to extract provider from registry for cleanup diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go new file mode 100644 index 000000000..a2ed068d6 --- /dev/null +++ b/pkg/agent/agent_command.go @@ -0,0 +1,492 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func (al *AgentLoop) handleCommand( + ctx context.Context, + msg bus.InboundMessage, + agent *AgentInstance, + opts *processOptions, +) (string, bool) { + normalizeProcessOptionsInPlace(opts) + + if !commands.HasCommandPrefix(msg.Content) { + return "", false + } + + if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched { + return reply, handled + } + + if al.cmdRegistry == nil { + return "", false + } + + rt := al.buildCommandsRuntime(ctx, agent, opts) + executor := commands.NewExecutor(al.cmdRegistry, rt) + + var commandReply string + result := executor.Execute(ctx, commands.Request{ + Channel: msg.Channel, + ChatID: msg.ChatID, + SenderID: msg.SenderID, + Text: msg.Content, + Reply: func(text string) error { + commandReply = text + return nil + }, + }) + + switch result.Outcome { + case commands.OutcomeHandled: + if result.Err != nil { + return mapCommandError(result), true + } + if commandReply != "" { + return commandReply, true + } + return "", true + default: // OutcomePassthrough — let the message fall through to LLM + return "", false + } +} + +func (al *AgentLoop) applyExplicitSkillCommand( + raw string, + agent *AgentInstance, + opts *processOptions, +) (matched bool, handled bool, reply string) { + normalizeProcessOptionsInPlace(opts) + + cmdName, ok := commands.CommandName(raw) + if !ok || cmdName != "use" { + return false, false, "" + } + + if agent == nil || agent.ContextBuilder == nil { + return true, true, commandsUnavailableSkillMessage() + } + + parts := strings.Fields(strings.TrimSpace(raw)) + if len(parts) < 2 { + return true, true, buildUseCommandHelp(agent) + } + + arg := strings.TrimSpace(parts[1]) + if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") { + if opts != nil { + al.clearPendingSkills(opts.Dispatch.SessionKey) + } + return true, true, "Cleared pending skill override." + } + + skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) + if !ok { + return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) + } + + if len(parts) < 3 { + if opts == nil || strings.TrimSpace(opts.Dispatch.SessionKey) == "" { + return true, true, commandsUnavailableSkillMessage() + } + al.setPendingSkills(opts.Dispatch.SessionKey, []string{skillName}) + return true, true, fmt.Sprintf( + "Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.", + skillName, + ) + } + + message := strings.TrimSpace(strings.Join(parts[2:], " ")) + if message == "" { + return true, true, buildUseCommandHelp(agent) + } + + if opts != nil { + opts.ForcedSkills = append(opts.ForcedSkills, skillName) + opts.Dispatch.UserMessage = message + opts.UserMessage = message + } + + return true, false, "" +} + +func (al *AgentLoop) buildCommandsRuntime( + ctx context.Context, + agent *AgentInstance, + opts *processOptions, +) *commands.Runtime { + normalizeProcessOptionsInPlace(opts) + + registry := al.GetRegistry() + cfg := al.GetConfig() + rt := &commands.Runtime{ + Config: cfg, + ListAgentIDs: registry.ListAgentIDs, + ListDefinitions: al.cmdRegistry.Definitions, + ListMCPServers: func(ctx context.Context) []commands.MCPServerInfo { + if cfg == nil { + return nil + } + + if len(cfg.Tools.MCP.Servers) == 0 { + return nil + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to refresh MCP status for command", + map[string]any{ + "error": err.Error(), + }) + } + + connected := make(map[string]int) + if manager := al.mcp.getManager(); manager != nil { + for serverName, conn := range manager.GetServers() { + connected[serverName] = len(conn.Tools) + } + } + + servers := make([]commands.MCPServerInfo, 0, len(cfg.Tools.MCP.Servers)) + for serverName, serverCfg := range cfg.Tools.MCP.Servers { + toolCount, isConnected := connected[serverName] + servers = append(servers, commands.MCPServerInfo{ + Name: serverName, + Enabled: serverCfg.Enabled, + Deferred: serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg), + Connected: isConnected, + ToolCount: toolCount, + }) + } + + sort.Slice(servers, func(i, j int) bool { + return strings.ToLower(servers[i].Name) < strings.ToLower(servers[j].Name) + }) + + return servers + }, + ListMCPTools: func(ctx context.Context, serverName string) ([]commands.MCPToolInfo, error) { + if cfg == nil { + return nil, fmt.Errorf("command unavailable: config not loaded") + } + + serverName = strings.TrimSpace(serverName) + if serverName == "" { + return nil, fmt.Errorf("server name is required") + } + + resolvedName := "" + var serverCfg config.MCPServerConfig + for name, candidate := range cfg.Tools.MCP.Servers { + if strings.EqualFold(name, serverName) { + resolvedName = name + serverCfg = candidate + break + } + } + if resolvedName == "" { + return nil, fmt.Errorf("MCP server '%s' is not configured", serverName) + } + if !serverCfg.Enabled { + return nil, fmt.Errorf("MCP server '%s' is configured but disabled", resolvedName) + } + if !cfg.Tools.IsToolEnabled("mcp") { + return nil, fmt.Errorf("MCP integration is disabled") + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to initialize MCP runtime for command", + map[string]any{ + "server": resolvedName, + "error": err.Error(), + }) + } + + manager := al.mcp.getManager() + if manager == nil { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + conn, ok := manager.GetServer(resolvedName) + if !ok { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + toolInfos := make([]commands.MCPToolInfo, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + if tool == nil { + continue + } + name := strings.TrimSpace(tool.Name) + if name == "" { + continue + } + + description := strings.TrimSpace(tool.Description) + if description == "" { + description = fmt.Sprintf("MCP tool from %s server", resolvedName) + } + + toolInfos = append(toolInfos, commands.MCPToolInfo{ + Name: name, + Description: description, + Parameters: summarizeMCPToolParameters(tool.InputSchema), + }) + } + sort.Slice(toolInfos, func(i, j int) bool { + return toolInfos[i].Name < toolInfos[j].Name + }) + return toolInfos, nil + }, + GetEnabledChannels: func() []string { + if al.channelManager == nil { + return nil + } + return al.channelManager.GetEnabledChannels() + }, + GetActiveTurn: func() any { + info := al.GetActiveTurn() + if info == nil { + return nil + } + return info + }, + SwitchChannel: func(value string) error { + if al.channelManager == nil { + return fmt.Errorf("channel manager not initialized") + } + if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { + return fmt.Errorf("channel '%s' not found or not enabled", value) + } + return nil + }, + } + if agent != nil && agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } + rt.ReloadConfig = func() error { + if al.reloadFunc == nil { + return fmt.Errorf("reload not configured") + } + return al.reloadFunc() + } + if agent != nil { + if agent.ContextBuilder != nil { + rt.ListSkillNames = agent.ContextBuilder.ListSkillNames + } + rt.GetModelInfo = func() (string, string) { + return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) + } + rt.SwitchModel = func(value string) (string, error) { + value = strings.TrimSpace(value) + modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) + if err != nil { + return "", err + } + + nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + return "", fmt.Errorf("failed to initialize model %q: %w", value, err) + } + + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks) + if len(nextCandidates) == 0 { + return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) + } + + oldModel := agent.Model + oldProvider := agent.Provider + agent.Model = value + agent.Provider = nextProvider + agent.Candidates = nextCandidates + agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) + + if oldProvider != nil && oldProvider != nextProvider { + if stateful, ok := oldProvider.(providers.StatefulProvider); ok { + stateful.Close() + } + } + return oldModel, nil + } + + rt.ClearHistory = func() error { + if opts == nil { + return fmt.Errorf("process options not available") + } + return al.contextManager.Clear(ctx, opts.SessionKey) + } + + rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) { + return al.askSideQuestion(ctx, agent, opts, question) + } + + rt.GetContextStats = func() *commands.ContextStats { + if opts == nil || agent.Sessions == nil { + return nil + } + usage := computeContextUsage(agent, opts.SessionKey) + if usage == nil { + return nil + } + history := agent.Sessions.GetHistory(opts.SessionKey) + return &commands.ContextStats{ + UsedTokens: usage.UsedTokens, + TotalTokens: usage.TotalTokens, + CompressAtTokens: usage.CompressAtTokens, + UsedPercent: usage.UsedPercent, + MessageCount: len(history), + } + } + } + return rt +} + +func summarizeMCPToolParameters(schema any) []commands.MCPToolParameterInfo { + schemaMap := normalizeMCPSchema(schema) + properties, ok := schemaMap["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return nil + } + + required := make(map[string]struct{}) + switch raw := schemaMap["required"].(type) { + case []string: + for _, name := range raw { + required[name] = struct{}{} + } + case []any: + for _, value := range raw { + name, ok := value.(string) + if ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]commands.MCPToolParameterInfo, 0, len(names)) + for _, name := range names { + param := commands.MCPToolParameterInfo{Name: name} + if propMap, ok := properties[name].(map[string]any); ok { + if typeName, ok := propMap["type"].(string); ok { + param.Type = strings.TrimSpace(typeName) + } + if desc, ok := propMap["description"].(string); ok { + param.Description = strings.TrimSpace(desc) + } + } + _, param.Required = required[name] + params = append(params, param) + } + return params +} + +func normalizeMCPSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + var jsonData []byte + switch raw := schema.(type) { + case json.RawMessage: + jsonData = raw + case []byte: + jsonData = raw + } + + if jsonData == nil { + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + return result +} + +func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" || len(skillNames) == 0 { + return + } + + filtered := make([]string, 0, len(skillNames)) + for _, name := range skillNames { + name = strings.TrimSpace(name) + if name != "" { + filtered = append(filtered, name) + } + } + if len(filtered) == 0 { + return + } + + al.pendingSkills.Store(sessionKey, filtered) +} + +func (al *AgentLoop) takePendingSkills(sessionKey string) []string { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return nil + } + + value, ok := al.pendingSkills.LoadAndDelete(sessionKey) + if !ok { + return nil + } + + skills, ok := value.([]string) + if !ok { + return nil + } + + return append([]string(nil), skills...) +} + +func (al *AgentLoop) clearPendingSkills(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingSkills.Delete(sessionKey) +} diff --git a/pkg/agent/agent_event.go b/pkg/agent/agent_event.go new file mode 100644 index 000000000..9b8625df1 --- /dev/null +++ b/pkg/agent/agent_event.go @@ -0,0 +1,188 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope { + seq := al.turnSeq.Add(1) + return turnEventScope{ + agentID: agentID, + sessionKey: sessionKey, + turnID: fmt.Sprintf("%s-turn-%d", agentID, seq), + context: cloneTurnContext(turnCtx), + } +} + +func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta { + return EventMeta{ + AgentID: ts.agentID, + TurnID: ts.turnID, + SessionKey: ts.sessionKey, + Iteration: iteration, + Source: source, + TracePath: tracePath, + turnContext: cloneTurnContext(ts.context), + } +} + +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, + } + + if al == nil || al.eventBus == 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) +} + +// MountHook registers an in-process hook on the agent loop. +func (al *AgentLoop) MountHook(reg HookRegistration) error { + if al == nil || al.hooks == nil { + return fmt.Errorf("hook manager is not initialized") + } + return al.hooks.Mount(reg) +} + +// UnmountHook removes a previously registered in-process hook. +func (al *AgentLoop) UnmountHook(name string) { + if al == nil || al.hooks == nil { + return + } + 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} + } + return al.eventBus.Subscribe(buffer) +} + +// UnsubscribeEvents removes a previously registered event subscriber. +func (al *AgentLoop) UnsubscribeEvents(id uint64) { + if al == nil || al.eventBus == nil { + return + } + al.eventBus.Unsubscribe(id) +} + +// 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 + } + return al.eventBus.Dropped(kind) +} diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go new file mode 100644 index 000000000..611d634e8 --- /dev/null +++ b/pkg/agent/agent_init.go @@ -0,0 +1,328 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/state" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func NewAgentLoop( + cfg *config.Config, + msgBus *bus.MessageBus, + provider providers.LLMProvider, +) *AgentLoop { + registry := NewAgentRegistry(cfg, provider) + + // Set up shared fallback chain with rate limiting. + cooldown := providers.NewCooldownTracker() + rl := providers.NewRateLimiterRegistry() + // Register rate limiters for all agents' candidates so that RPM limits + // configured in ModelConfig are enforced before each LLM call. + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + rl.RegisterCandidates(agent.Candidates) + rl.RegisterCandidates(agent.LightCandidates) + } + } + fallbackChain := providers.NewFallbackChain(cooldown, rl) + + // Create state manager using default agent's workspace for channel recording + defaultAgent := registry.GetDefaultAgent() + var stateManager *state.Manager + if defaultAgent != nil { + stateManager = state.NewManager(defaultAgent.Workspace) + } + + eventBus := NewEventBus() + + // Determine worker pool size from config (default: 1 = sequential) + workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns + if workerPoolSize <= 0 { + workerPoolSize = 1 + } + + 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), + } + al.providerFactory = providers.CreateProviderFromConfig + al.hooks = NewHookManager(eventBus) + configureHookManagerFromConfig(al.hooks, cfg) + al.contextManager = al.resolveContextManager() + + // Register shared tools to all agents (now that al is created) + registerSharedTools(al, cfg, msgBus, registry, provider) + + return al +} + +func registerSharedTools( + al *AgentLoop, + cfg *config.Config, + msgBus interfaces.MessageBus, + registry *AgentRegistry, + provider providers.LLMProvider, +) { + allowReadPaths := buildAllowReadPatterns(cfg) + var ttsProvider tts.TTSProvider + if cfg.Tools.IsToolEnabled("send_tts") { + ttsProvider = tts.DetectTTS(cfg) + if ttsProvider == nil { + logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil) + } + } + + for _, agentID := range registry.ListAgentIDs() { + agent, ok := registry.GetAgent(agentID) + if !ok { + continue + } + + if cfg.Tools.IsToolEnabled("web") { + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptionsFromConfig(cfg)) + if err != nil { + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) + } else if searchTool != nil { + agent.Tools.Register(searchTool) + } + } + if cfg.Tools.IsToolEnabled("web_fetch") { + fetchTool, err := tools.NewWebFetchToolWithProxy( + 50000, + cfg.Tools.Web.Proxy, + cfg.Tools.Web.Format, + cfg.Tools.Web.FetchLimitBytes, + cfg.Tools.Web.PrivateHostWhitelist) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else { + agent.Tools.Register(fetchTool) + } + } + + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + if cfg.Tools.IsToolEnabled("i2c") { + agent.Tools.Register(tools.NewI2CTool()) + } + if cfg.Tools.IsToolEnabled("spi") { + agent.Tools.Register(tools.NewSPITool()) + } + + // Message tool + if cfg.Tools.IsToolEnabled("message") { + messageTool := tools.NewMessageTool() + messageTool.SetSendCallback(func( + ctx context.Context, + channel, chatID, content, replyToMessageID string, + ) error { + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID) + outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata( + tools.ToolAgentID(ctx), + tools.ToolSessionKey(ctx), + tools.ToolSessionScope(ctx), + ) + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: outboundCtx, + AgentID: outboundAgentID, + SessionKey: outboundSessionKey, + Scope: outboundScope, + Content: content, + ReplyToMessageID: replyToMessageID, + }) + }) + agent.Tools.Register(messageTool) + } + if cfg.Tools.IsToolEnabled("reaction") { + reactionTool := tools.NewReactionTool() + reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { + if al.channelManager == nil { + return fmt.Errorf("channel manager not configured") + } + ch, ok := al.channelManager.GetChannel(channel) + if !ok { + return fmt.Errorf("channel %s not found", channel) + } + rc, ok := ch.(channels.ReactionCapable) + if !ok { + return fmt.Errorf("channel %s does not support reactions", channel) + } + _, err := rc.ReactToMessage(ctx, chatID, messageID) + return err + }) + agent.Tools.Register(reactionTool) + } + + // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) + if cfg.Tools.IsToolEnabled("send_file") { + sendFileTool := tools.NewSendFileTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(sendFileTool) + } + + if ttsProvider != nil { + agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) + } + + if cfg.Tools.IsToolEnabled("load_image") { + loadImageTool := tools.NewLoadImageTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(loadImageTool) + } + + // Skill discovery and installation tools + skills_enabled := cfg.Tools.IsToolEnabled("skills") + find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") + install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") + if skills_enabled && (find_skills_enable || install_skills_enable) { + registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) + + if find_skills_enable { + searchCache := skills.NewSearchCache( + cfg.Tools.Skills.SearchCache.MaxSize, + time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, + ) + agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) + } + + if install_skills_enable { + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + } + } + + // Spawn and spawn_status tools share a SubagentManager. + // Construct it when either tool is enabled (both require subagent). + spawnEnabled := cfg.Tools.IsToolEnabled("spawn") + spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status") + if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + + // Inject a media resolver so the legacy RunToolLoop fallback path can + // resolve media:// refs in the same way the main AgentLoop does. + // This keeps subagent vision support working even when the optimized + // sub-turn spawner path is unavailable. + subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize()) + }) + + // Set the spawner that links into AgentLoop's turnState + subagentManager.SetSpawner(func( + ctx context.Context, + task, label, targetAgentID string, + tls *tools.ToolRegistry, + maxTokens int, + temperature float64, + hasMaxTokens, hasTemperature bool, + ) (*tools.ToolResult, error) { + // 1. Recover parent Turn State from Context + parentTS := turnStateFromContext(ctx) + if parentTS == nil { + // Fallback: If no turnState exists in context, create an isolated ad-hoc root turn state + // so that the tool can still function outside of an agent loop (e.g. tests, raw invocations). + parentTS = &turnState{ + ctx: ctx, + turnID: "adhoc-root", + depth: 0, + session: nil, // Ephemeral session not needed for adhoc spawn + pendingResults: make(chan *tools.ToolResult, 16), + concurrencySem: make(chan struct{}, 5), + } + } + + // 2. Build Tools slice from registry + var tlSlice []tools.Tool + for _, name := range tls.List() { + if t, ok := tls.Get(name); ok { + tlSlice = append(tlSlice, t) + } + } + + // 3. System Prompt + systemPrompt := "You are a subagent. Complete the given task independently and report the result.\n" + + "You have access to tools - use them as needed to complete your task.\n" + + "After completing the task, provide a clear summary of what was done.\n\n" + + "Task: " + task + + // 4. Resolve Model + modelToUse := agent.Model + if targetAgentID != "" { + if targetAgent, ok := al.GetRegistry().GetAgent(targetAgentID); ok { + modelToUse = targetAgent.Model + } + } + + // 5. Build SubTurnConfig + cfg := SubTurnConfig{ + Model: modelToUse, + Tools: tlSlice, + SystemPrompt: systemPrompt, + } + if hasMaxTokens { + cfg.MaxTokens = maxTokens + } + + // 6. Spawn SubTurn + return spawnSubTurn(ctx, al, parentTS, cfg) + }) + + // Clone the parent's tool registry so subagents can use all + // tools registered so far (file, web, etc.) but NOT spawn/ + // spawn_status which are added below — preventing recursive + // subagent spawning. + subagentManager.SetTools(agent.Tools.Clone()) + if spawnEnabled { + spawnTool := tools.NewSpawnTool(subagentManager) + spawnTool.SetSpawner(NewSubTurnSpawner(al)) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + + agent.Tools.Register(spawnTool) + + // Also register the synchronous subagent tool + subagentTool := tools.NewSubagentTool(subagentManager) + subagentTool.SetSpawner(NewSubTurnSpawner(al)) + agent.Tools.Register(subagentTool) + } + if spawnStatusEnabled { + agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) + } + } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { + logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) + } + } +} diff --git a/pkg/agent/agent_inject.go b/pkg/agent/agent_inject.go new file mode 100644 index 000000000..6c0ad10da --- /dev/null +++ b/pkg/agent/agent_inject.go @@ -0,0 +1,103 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/audio/asr" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func (al *AgentLoop) RegisterTool(tool tools.Tool) { + registry := al.GetRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + agent.Tools.Register(tool) + } + } +} + +func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { + al.channelManager = cm +} + +func (al *AgentLoop) GetRegistry() *AgentRegistry { + al.mu.RLock() + defer al.mu.RUnlock() + return al.registry +} + +func (al *AgentLoop) GetConfig() *config.Config { + al.mu.RLock() + defer al.mu.RUnlock() + return al.cfg +} + +func (al *AgentLoop) SetMediaStore(s media.MediaStore) { + al.mediaStore = s + + // Propagate store to all registered tools that can emit media. + registry := al.GetRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + agent.Tools.SetMediaStore(s) + } + } + registry.ForEachTool("send_tts", func(t tools.Tool) { + if st, ok := t.(*tools.SendTTSTool); ok { + st.SetMediaStore(s) + } + }) +} + +func (al *AgentLoop) SetTranscriber(t asr.Transcriber) { + al.transcriber = t +} + +func (al *AgentLoop) SetReloadFunc(fn func() error) { + al.reloadFunc = fn +} + +func (al *AgentLoop) RecordLastChannel(channel string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChannel(channel) +} + +func (al *AgentLoop) RecordLastChatID(chatID string) error { + if al.state == nil { + return nil + } + return al.state.SetLastChatID(chatID) +} + +func (al *AgentLoop) GetStartupInfo() map[string]any { + info := make(map[string]any) + + registry := al.GetRegistry() + agent := registry.GetDefaultAgent() + if agent == nil { + return info + } + + // Tools info + toolsList := agent.Tools.List() + info["tools"] = map[string]any{ + "count": len(toolsList), + "names": toolsList, + } + + // Skills info + info["skills"] = agent.ContextBuilder.GetSkillsInfo() + + // Agents info + info["agents"] = map[string]any{ + "count": len(registry.ListAgentIDs()), + "ids": registry.ListAgentIDs(), + } + + return info +} diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/agent_mcp.go similarity index 89% rename from pkg/agent/loop_mcp.go rename to pkg/agent/agent_mcp.go index 21b6b9eb2..fcb57a5d4 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -67,6 +67,12 @@ func (r *mcpRuntime) hasManager() bool { return r.manager != nil } +func (r *mcpRuntime) getManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager +} + // ensureMCPInitialized loads MCP servers/tools once so both Run() and direct // agent mode share the same initialization path. func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { @@ -100,6 +106,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + al.mcp.setInitErr(fmt.Errorf("failed to load MCP servers: %w", err)) logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", map[string]any{ "error": err.Error(), @@ -128,6 +135,25 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { serverCfg := al.cfg.Tools.MCP.Servers[serverName] registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg) + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok || agent.ContextBuilder == nil { + continue + } + if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: serverName, + toolCount: len(conn.Tools), + deferred: registerAsHidden, + }); err != nil { + logger.WarnCF("agent", "Failed to register MCP prompt contributor", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "error": err.Error(), + }) + } + } + for _, tool := range conn.Tools { for _, agentID := range agentIDs { agent, ok := al.registry.GetAgent(agentID) diff --git a/pkg/agent/loop_mcp_test.go b/pkg/agent/agent_mcp_test.go similarity index 71% rename from pkg/agent/loop_mcp_test.go rename to pkg/agent/agent_mcp_test.go index 1c810f003..b68fcc2c1 100644 --- a/pkg/agent/loop_mcp_test.go +++ b/pkg/agent/agent_mcp_test.go @@ -9,6 +9,7 @@ package agent import ( "context" "errors" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" @@ -133,3 +134,48 @@ func TestServerIsDeferred(t *testing.T) { }) } } + +func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + defer al.Close() + + cfg.Tools = config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "broken": { + Enabled: true, + Command: "picoclaw-command-that-does-not-exist-for-mcp-tests", + }, + }, + }, + } + + err := al.ensureMCPInitialized(context.Background()) + if err == nil { + t.Fatal("ensureMCPInitialized() error = nil, want load failure") + } + if !strings.Contains(err.Error(), "failed to load MCP servers") { + t.Fatalf("ensureMCPInitialized() error = %q, want wrapped load failure", err.Error()) + } + + initErr := al.mcp.getInitErr() + if initErr == nil { + t.Fatal("getInitErr() = nil, want cached load failure") + } + if !strings.Contains(initErr.Error(), "failed to load MCP servers") { + t.Fatalf("getInitErr() = %q, want wrapped load failure", initErr.Error()) + } + if al.mcp.getManager() != nil { + t.Fatal("expected MCP manager to remain nil after load failure") + } + + err = al.ensureMCPInitialized(context.Background()) + if err == nil { + t.Fatal("second ensureMCPInitialized() error = nil, want cached load failure") + } + if !strings.Contains(err.Error(), "failed to load MCP servers") { + t.Fatalf("second ensureMCPInitialized() error = %q, want wrapped load failure", err.Error()) + } +} diff --git a/pkg/agent/loop_media.go b/pkg/agent/agent_media.go similarity index 89% rename from pkg/agent/loop_media.go rename to pkg/agent/agent_media.go index e8314c10d..a773d2ebb 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/agent_media.go @@ -105,6 +105,25 @@ func buildArtifactTags(store media.MediaStore, refs []string) []string { return tags } +func buildProviderAttachments(store media.MediaStore, refs []string) []providers.Attachment { + if store == nil || len(refs) == 0 { + return nil + } + + attachments := make([]providers.Attachment, 0, len(refs)) + for _, ref := range refs { + attachment := providers.Attachment{Ref: ref} + if _, meta, err := store.ResolveWithMeta(ref); err == nil { + attachment.Filename = meta.Filename + attachment.ContentType = meta.ContentType + attachment.Type = inferMediaType(meta.Filename, meta.ContentType) + } + attachments = append(attachments, attachment) + } + + return attachments +} + // detectMIME determines the MIME type from metadata or magic-bytes detection. // Returns empty string if detection fails. func detectMIME(localPath string, meta media.MediaMeta) string { diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go new file mode 100644 index 000000000..96b0b0817 --- /dev/null +++ b/pkg/agent/agent_message.go @@ -0,0 +1,302 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) { + if msg.Channel == "system" { + return nil, nil + } + + route, _, err := al.resolveMessageRoute(msg) + if err != nil { + return nil, err + } + allocation := al.allocateRouteSession(route, msg) + + return &continuationTarget{ + SessionKey: resolveScopeKey(allocation.SessionKey, msg.SessionKey), + Channel: msg.Channel, + ChatID: msg.ChatID, + }, nil +} + +func (al *AgentLoop) ProcessDirect( + ctx context.Context, + content, sessionKey string, +) (string, error) { + return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") +} + +func (al *AgentLoop) ProcessDirectWithChannel( + ctx context.Context, + content, sessionKey, channel, chatID string, +) (string, error) { + if err := al.ensureHooksInitialized(ctx); err != nil { + return "", err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: channel, + ChatID: chatID, + ChatType: "direct", + SenderID: "cron", + }, + Content: content, + SessionKey: sessionKey, + } + + return al.processMessage(ctx, msg) +} + +func (al *AgentLoop) ProcessHeartbeat( + ctx context.Context, + content, channel, chatID string, +) (string, error) { + if err := al.ensureHooksInitialized(ctx); err != nil { + return "", err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for heartbeat") + } + dispatch := DispatchRequest{ + SessionKey: "heartbeat", + UserMessage: content, + } + if channel != "" || chatID != "" { + dispatch.InboundContext = &bus.InboundContext{ + Channel: channel, + ChatID: chatID, + ChatType: "direct", + SenderID: "heartbeat", + } + } + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: dispatch, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + SuppressToolFeedback: true, + NoHistory: true, // Don't load session history for heartbeat + }) +} + +func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { + msg = bus.NormalizeInboundMessage(msg) + + // Add message preview to log (show full content for error messages) + var logContent string + if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { + logContent = msg.Content // Full content for errors + } else { + logContent = utils.Truncate(msg.Content, 80) + } + logger.InfoCF( + "agent", + fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), + map[string]any{ + "channel": msg.Channel, + "chat_id": msg.ChatID, + "sender_id": msg.SenderID, + "session_key": msg.SessionKey, + }, + ) + + var hadAudio bool + msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) + + // For audio messages the placeholder was deferred by the channel. + // Now that transcription (and optional feedback) is done, send it. + if hadAudio && al.channelManager != nil { + al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) + } + + // Route system messages to processSystemMessage + if msg.Channel == "system" { + return al.processSystemMessage(ctx, msg) + } + + route, agent, routeErr := al.resolveMessageRoute(msg) + if routeErr != nil { + return "", routeErr + } + + allocation := al.allocateRouteSession(route, msg) + + // Resolve session key from the route allocation, while preserving explicit + // agent-scoped keys supplied by the caller. + scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) + sessionKey := scopeKey + + // Reset message-tool state for this round so we don't skip publishing due to a previous round. + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + + logger.InfoCF("agent", "Routed message", + map[string]any{ + "agent_id": agent.ID, + "scope_key": scopeKey, + "session_key": sessionKey, + "matched_by": route.MatchedBy, + "route_agent": route.AgentID, + "route_channel": route.Channel, + "route_main_session": allocation.MainSessionKey, + }) + + opts := processOptions{ + Dispatch: DispatchRequest{ + SessionKey: sessionKey, + SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...), + InboundContext: cloneInboundContext(&msg.Context), + RouteResult: cloneResolvedRoute(&route), + SessionScope: session.CloneScope(&allocation.Scope), + UserMessage: msg.Content, + Media: append([]string(nil), msg.Media...), + }, + SenderID: msg.SenderID, + SenderDisplayName: msg.Sender.DisplayName, + DefaultResponse: defaultResponse, + EnableSummary: true, + SendResponse: false, + AllowInterimPicoPublish: true, + } + + // context-dependent commands check their own Runtime fields and report + // "unavailable" when the required capability is nil. + if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { + return response, nil + } + + if pending := al.takePendingSkills(opts.Dispatch.SessionKey); len(pending) > 0 { + opts.ForcedSkills = append(opts.ForcedSkills, pending...) + logger.InfoCF("agent", "Applying pending skill override", + map[string]any{ + "session_key": opts.Dispatch.SessionKey, + "skills": strings.Join(pending, ","), + }) + } + + return al.runAgentLoop(ctx, agent, opts) +} + +func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { + registry := al.GetRegistry() + inboundCtx := normalizedInboundContext(msg) + route := registry.ResolveRoute(inboundCtx) + + agent, ok := registry.GetAgent(route.AgentID) + if !ok { + agent = registry.GetDefaultAgent() + } + if agent == nil { + return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + } + + return route, agent, nil +} + +func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation { + return session.AllocateRouteSession(session.AllocationInput{ + AgentID: route.AgentID, + Context: normalizedInboundContext(msg), + SessionPolicy: route.SessionPolicy, + }) +} + +func (al *AgentLoop) processSystemMessage( + ctx context.Context, + msg bus.InboundMessage, +) (string, error) { + if msg.Channel != "system" { + return "", fmt.Errorf( + "processSystemMessage called with non-system message channel: %s", + msg.Channel, + ) + } + + logger.InfoCF("agent", "Processing system message", + map[string]any{ + "sender_id": msg.SenderID, + "chat_id": msg.ChatID, + }) + + // Parse origin channel from chat_id (format: "channel:chat_id") + var originChannel, originChatID string + if idx := strings.Index(msg.ChatID, ":"); idx > 0 { + originChannel = msg.ChatID[:idx] + originChatID = msg.ChatID[idx+1:] + } else { + originChannel = "cli" + originChatID = msg.ChatID + } + + // Extract subagent result from message content + // Format: "Task 'label' completed.\n\nResult:\n" + content := msg.Content + if idx := strings.Index(content, "Result:\n"); idx >= 0 { + content = content[idx+8:] // Extract just the result part + } + + // Skip internal channels - only log, don't send to user + if constants.IsInternalChannel(originChannel) { + logger.InfoCF("agent", "Subagent completed (internal channel)", + map[string]any{ + "sender_id": msg.SenderID, + "content_len": len(content), + "channel": originChannel, + }) + return "", nil + } + + // Use default agent for system messages + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for system message") + } + + // Use the origin session for context + sessionKey := session.BuildMainSessionKey(agent.ID) + dispatch := DispatchRequest{ + SessionKey: sessionKey, + UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), + } + if originChannel != "" || originChatID != "" { + dispatch.InboundContext = &bus.InboundContext{ + Channel: originChannel, + ChatID: originChatID, + ChatType: "direct", + SenderID: msg.SenderID, + } + } + + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: dispatch, + DefaultResponse: "Background task completed.", + EnableSummary: false, + SendResponse: true, + }) +} diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go new file mode 100644 index 000000000..1728f6f79 --- /dev/null +++ b/pkg/agent/agent_outbound.go @@ -0,0 +1,262 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool { + if errors.Is(err, context.Canceled) { + return false + } + al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err)) + return true +} + +func (al *AgentLoop) publishResponseOrError( + ctx context.Context, + channel, chatID, sessionKey string, + response string, + err error, +) { + if err != nil { + if !al.maybePublishError(ctx, channel, chatID, sessionKey, err) { + return + } + response = "" + } + al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response) +} + +func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) { + if response == "" { + return + } + + alreadySentToSameChat := false + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID) + } + } + } + + if alreadySentToSameChat { + logger.DebugCF( + "agent", + "Skipped outbound (message tool already sent to same chat)", + map[string]any{"channel": channel, "chat_id": chatID}, + ) + return + } + + msg := bus.OutboundMessage{ + Context: bus.NewOutboundContext(channel, chatID, ""), + Content: response, + } + if sessionKey != "" { + msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey) + } + al.bus.PublishOutbound(ctx, msg) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": channel, + "chat_id": chatID, + "content_len": len(response), + }) +} + +func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { + if al.channelManager == nil { + return "" + } + if ch, ok := al.channelManager.GetChannel(channelName); ok { + return ch.ReasoningChannelID() + } + return "" +} + +func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) { + if reasoningContent == "" || chatID == "" { + return + } + + if ctx.Err() != nil { + return + } + + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: bus.InboundContext{ + Channel: "pico", + ChatID: chatID, + Raw: map[string]string{ + metadataKeyMessageKind: messageKindThought, + }, + }, + Content: reasoningContent, + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{ + "channel": "pico", + "error": err.Error(), + }) + } + } +} + +func (al *AgentLoop) publishPicoToolCallInterim( + ctx context.Context, + ts *turnState, + reasoningContent string, + content string, + toolCalls []providers.ToolCall, +) { + if ts == nil || ts.chatID == "" || al == nil || al.bus == nil { + return + } + + if strings.TrimSpace(reasoningContent) != "" { + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err := al.bus.PublishOutbound( + pubCtx, + outboundMessageForTurnWithKind(ts, reasoningContent, messageKindThought), + ) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico reasoning", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } + } + + if !ts.opts.AllowInterimPicoPublish { + return + } + + visibleToolCalls := utils.BuildVisibleToolCalls( + toolCalls, + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + duplicateToolCallContent := len(visibleToolCalls) > 0 && + utils.ToolCallExplanationDuplicatesContent(content, toolCalls) + + if strings.TrimSpace(content) != "" && !duplicateToolCallContent { + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err := al.bus.PublishOutbound(pubCtx, outboundMessageForTurn(ts, content)) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico interim assistant content", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } + } + + if len(visibleToolCalls) == 0 { + return + } + + rawToolCalls, err := json.Marshal(visibleToolCalls) + if err != nil { + logger.WarnCF("agent", "Failed to serialize pico tool calls", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + return + } + + msg := outboundMessageForTurnWithKind(ts, "", messageKindToolCalls) + if msg.Context.Raw == nil { + msg.Context.Raw = map[string]string{} + } + msg.Context.Raw[metadataKeyToolCalls] = string(rawToolCalls) + + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err = al.bus.PublishOutbound(pubCtx, msg) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico tool calls", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } +} + +func (al *AgentLoop) handleReasoning( + ctx context.Context, + reasoningContent, channelName, channelID string, +) { + if reasoningContent == "" || channelName == "" || channelID == "" { + return + } + + // Check context cancellation before attempting to publish, + // since PublishOutbound's select may race between send and ctx.Done(). + if ctx.Err() != nil { + return + } + + // Use a short timeout so the goroutine does not block indefinitely when + // the outbound bus is full. Reasoning output is best-effort; dropping it + // is acceptable to avoid goroutine accumulation. + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channelName, channelID, ""), + Content: reasoningContent, + }); err != nil { + // Treat context.DeadlineExceeded / context.Canceled as expected + // (bus full under load, or parent canceled). Check the error + // itself rather than ctx.Err(), because pubCtx may time out + // (5 s) while the parent ctx is still active. + // Also treat ErrBusClosed as expected — it occurs during normal + // shutdown when the bus is closed before all goroutines finish. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } + } +} diff --git a/pkg/agent/agent_steering.go b/pkg/agent/agent_steering.go new file mode 100644 index 000000000..c674bcafa --- /dev/null +++ b/pkg/agent/agent_steering.go @@ -0,0 +1,96 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) { + if al.channelManager != nil { + defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + + response, err := al.processMessage(ctx, msg) + al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err) +} + +func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) { + // Process the initial message + response, err := al.processMessage(ctx, initialMsg) + if err != nil { + if !al.maybePublishError(ctx, initialMsg.Channel, initialMsg.ChatID, initialMsg.SessionKey, err) { + return // context canceled + } + response = "" + } + finalResponse := response + + // Build continuation target + target, targetErr := al.buildContinuationTarget(initialMsg) + if targetErr != nil { + logger.WarnCF("agent", "Failed to build steering continuation target", + map[string]any{ + "channel": initialMsg.Channel, + "error": targetErr.Error(), + }) + return + } + if target == nil { + // System message or non-routable, response already published + return + } + + // Drain steering queue using existing Continue mechanism + for al.pendingSteeringCountForScope(target.SessionKey) > 0 { + // Check for context cancellation between iterations + if ctx.Err() != nil { + return + } + + logger.InfoCF("agent", "Continuing queued steering after turn end", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "session_key": target.SessionKey, + "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), + }) + + 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 + } + if continued == "" { + break + } + finalResponse = continued + } + + // Publish final response + if finalResponse != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) + } +} + +func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { + if msg.Channel == "system" { + return "", "", false + } + + route, agent, err := al.resolveMessageRoute(msg) + if err != nil || agent == nil { + return "", "", false + } + allocation := al.allocateRouteSession(route, msg) + + return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/agent_test.go similarity index 72% rename from pkg/agent/loop_test.go rename to pkg/agent/agent_test.go index e01f74e46..4047ab74d 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/agent_test.go @@ -9,8 +9,10 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "slices" "strings" + "sync" "testing" "time" @@ -22,6 +24,7 @@ import ( "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" ) type fakeChannel struct{ id string } @@ -80,6 +83,7 @@ func newStartedTestChannelManager( type recordingProvider struct { lastMessages []providers.Message + lastModel string } func (r *recordingProvider) Chat( @@ -90,6 +94,7 @@ func (r *recordingProvider) Chat( opts map[string]any, ) (*providers.LLMResponse, error) { r.lastMessages = append([]providers.Message(nil), messages...) + r.lastModel = model return &providers.LLMResponse{ Content: "Mock response", ToolCalls: []providers.ToolCall{}, @@ -100,6 +105,38 @@ func (r *recordingProvider) GetDefaultModel() string { return "mock-model" } +type modelRewriteHook struct { + model string +} + +func (h modelRewriteHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Model = h.model + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h modelRewriteHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +func useTestSideQuestionProvider(al *AgentLoop, provider providers.LLMProvider) { + al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) { + model := provider.GetDefaultModel() + if mc != nil { + if _, modelID := providers.ExtractProtocol(mc); modelID != "" { + model = modelID + } + } + return provider, model, nil + } +} + func newTestAgentLoop( t *testing.T, ) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { @@ -124,6 +161,58 @@ func newTestAgentLoop( return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } } +func TestNewAgentLoop_RegistersWebSearchTool(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); !ok { + t.Fatal("expected web_search tool to be registered") + } +} + +func TestNewAgentLoop_RegistersWebSearchTool_WhenExplicitProviderUnavailable(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); !ok { + t.Fatal("expected web_search tool to fall back to auto provider selection") + } +} + +func TestNewAgentLoop_DoesNotRegisterWebSearchTool_WhenNoReadyProviders(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); ok { + t.Fatal("expected web_search tool to be absent when no providers are ready") + } +} + func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { @@ -235,6 +324,330 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { } } +func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + // Add model list so isolated provider can resolve the model + ModelList: []*config.ModelConfig{ + {ModelName: "test-model", Model: "openai/test-model"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain side effects", + } + route, _, err := al.resolveMessageRoute(msg) + if err != nil { + t.Fatalf("resolveMessageRoute() error = %v", err) + } + allocation := al.allocateRouteSession(route, msg) + sessionKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) + initialHistory := []providers.Message{ + {Role: "user", Content: "We decided to avoid global state."}, + {Role: "assistant", Content: "Right, keep it request-scoped."}, + } + defaultAgent.Sessions.SetHistory(sessionKey, initialHistory) + defaultAgent.Sessions.SetSummary(sessionKey, "The team decided to keep state request-scoped.") + + response, err := al.processMessage(context.Background(), msg) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + if len(provider.lastMessages) != 4 { + t.Fatalf("provider messages len = %d, want 4 (system + prior history + user)", len(provider.lastMessages)) + } + + if !reflect.DeepEqual(provider.lastMessages[1:3], initialHistory) { + t.Fatalf("provider history = %#v, want %#v", provider.lastMessages[1:3], initialHistory) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain side effects" { + t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) + } + + history := al.GetRegistry().GetDefaultAgent().Sessions.GetHistory(sessionKey) + if !reflect.DeepEqual(history, initialHistory) { + t.Fatalf("session history = %#v, want %#v", history, initialHistory) + } +} + +func TestProcessMessage_BtwCommandIncludesRequestContextAndMedia(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "discord", + SenderID: "discord:123", + Sender: bus.SenderInfo{ + DisplayName: "Alice", + }, + ChatID: "group-1", + Content: "/btw describe this image", + Media: []string{"media://image-1"}, + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages") + } + + systemPrompt := provider.lastMessages[0].Content + if !strings.Contains(systemPrompt, "## Current Session\nChannel: discord\nChat ID: group-1") { + t.Fatalf("system prompt missing current session context:\n%s", systemPrompt) + } + if !strings.Contains(systemPrompt, "## Current Sender\nCurrent sender: Alice (ID: discord:123)") { + t.Fatalf("system prompt missing current sender context:\n%s", systemPrompt) + } + + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "describe this image" { + t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) + } + if !reflect.DeepEqual(lastMessage.Media, []string{"media://image-1"}) { + t.Fatalf("last provider media = %#v, want media ref", lastMessage.Media) + } +} + +func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + // Add model list so isolated provider can resolve the model + ModelList: []*config.ModelConfig{ + {ModelName: "test-model", Model: "openai/test-model"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Set up initial history for the main session + mainSessionKey := "telegram:123:chat-1" + initialHistory := []providers.Message{ + {Role: "user", Content: "We decided to avoid global state."}, + {Role: "assistant", Content: "Right, keep it request-scoped."}, + } + defaultAgent.Sessions.SetHistory(mainSessionKey, initialHistory) + + // Process a /btw command + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + SessionKey: mainSessionKey, + Content: "/btw explain isolation", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + + // Verify the provider received the side question + if len(provider.lastMessages) == 0 { + t.Fatal("provider did not receive any messages for /btw command") + } + + // Verify the question was stripped of /btw prefix + lastMessage := provider.lastMessages[len(provider.lastMessages)-1] + if lastMessage.Role != "user" || lastMessage.Content != "explain isolation" { + t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage) + } + + // Verify main session history was NOT modified + currentHistory := defaultAgent.Sessions.GetHistory(mainSessionKey) + if !reflect.DeepEqual(currentHistory, initialHistory) { + t.Fatalf("main session history was modified:\ngot %#v\nwant %#v", currentHistory, initialHistory) + } +} + +func TestProcessMessage_BtwCommandRetriesWithoutMediaOnVisionUnsupported(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + // Add model list so isolated provider can resolve the model + ModelList: []*config.ModelConfig{ + {ModelName: "test-model", Model: "openai/test-model"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &visionUnsupportedMediaProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw describe this image", + Media: []string{"data:image/png;base64,abc123"}, + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "ok" { + t.Fatalf("processMessage() response = %q, want %q", response, "ok") + } + // Note: With isolated providers, each /btw creates a new provider instance, + // so we can't track calls across retries in the same way. + // The retry logic happens within askSideQuestion, creating separate isolated providers. + // For now, we just verify the command succeeds. + if provider.calls < 1 { + t.Fatalf("provider was not called for /btw command") + } +} + +func TestProcessMessage_BtwCommandUsesProviderFactoryModel(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "lb-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + ModelList: []*config.ModelConfig{ + {ModelName: "lb-model", Model: "openai/lb-model-a"}, + {ModelName: "lb-model", Model: "openai/lb-model-b"}, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain load balancing", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + + // Verify that /btw used the configured model from ModelList + // The provider should have been called with one of the lb-model variants + if provider.lastModel == "" { + t.Fatal("provider was not called for /btw command") + } + if !strings.HasPrefix(provider.lastModel, "lb-model") { + t.Fatalf("/btw used model %q, expected lb-model variant", provider.lastModel) + } +} + +func TestProcessMessage_BtwCommandHookModelBypassesFallbackCandidates(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "primary-model", + ModelFallbacks: []string{"fallback-model"}, + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + useTestSideQuestionProvider(al, provider) + if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "hook-model"})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "/btw explain hook routing", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + } + if provider.lastModel != "hook-model" { + t.Fatalf("/btw model = %q, want hook-selected model", provider.lastModel) + } +} + func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) { tmpDir := t.TempDir() cfg := &config.Config{ @@ -691,6 +1104,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." { t.Fatalf("expected handled assistant summary in history, got %+v", last) } + if len(last.Attachments) != 1 { + t.Fatalf("expected handled assistant summary attachments in history, got %+v", last.Attachments) + } } func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *testing.T) { @@ -1304,6 +1720,38 @@ func (m *messageToolProvider) GetDefaultModel() string { return "message-tool-model" } +type reasoningVisibleToolProvider struct { + filePath string + calls int +} + +func (m *reasoningVisibleToolProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "I'll inspect that file now.", + ReasoningContent: "Read the file before answering.", + ToolCalls: []providers.ToolCall{{ + ID: "call_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + return &providers.LLMResponse{Content: "DONE"}, nil +} + +func (m *reasoningVisibleToolProvider) GetDefaultModel() string { + return "reasoning-visible-tool-model" +} + type artifactThenSendProvider struct { calls int } @@ -1398,6 +1846,208 @@ func (m *toolFeedbackProvider) GetDefaultModel() string { return "heartbeat-tool-feedback-model" } +type toolFeedbackReasoningProvider struct { + filePath string + calls int +} + +func (m *toolFeedbackReasoningProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ReasoningContent: "Read README.md first to confirm the context that needs to be changed.", + ToolCalls: []providers.ToolCall{{ + ID: "call_reasoning_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "DONE", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolFeedbackReasoningProvider) GetDefaultModel() string { + return "tool-feedback-reasoning-model" +} + +func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Read README.md first", + ReasoningContent: "current reasoning fallback", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages) + if got != "Read README.md first" { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got) + } +} + +func TestSideQuestionResponseContent_FallsBackWhenContentIsWhitespace(t *testing.T) { + response := &providers.LLMResponse{ + Content: " \n\t ", + ReasoningContent: "reasoning fallback", + } + + if got := sideQuestionResponseContent(response); got != "reasoning fallback" { + t.Fatalf("sideQuestionResponseContent() = %q, want %q", got, "reasoning fallback") + } +} + +func TestResponseReasoningContent_FallsBackWhenReasoningIsWhitespace(t *testing.T) { + response := &providers.LLMResponse{ + Reasoning: " \n\t ", + ReasoningContent: "structured reasoning fallback", + } + + if got := responseReasoningContent(response); got != "structured reasoning fallback" { + t.Fatalf("responseReasoningContent() = %q, want %q", got, "structured reasoning fallback") + } +} + +func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first to confirm the current project structure.", + }, + }}, + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: ""}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages) + if got != "Read README.md first to confirm the current project structure." { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got) + } +} + +func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Shared explanation", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first.", + }, + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + + got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil) + got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil) + if got1 != "Read README.md first." { + t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1) + } + if got2 != "Update config example after reading it." { + t.Fatalf("toolFeedbackExplanationForToolCall() second = %q, want tool-specific explanation", got2) + } +} + +func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanation(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + messages := []providers.Message{ + {Role: "user", Content: "inspect the config and update the example"}, + } + + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages) + want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example" + if got != want { + t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want) + } +} + +func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "", + ReasoningContent: "hidden reasoning should not be shown", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "user", Content: "Inspect README.md and update the config example."}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages) + want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example." + if got != want { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got) + } +} + +func TestToolFeedbackExplanationForToolCall_DoesNotTruncateLongExplanation(t *testing.T) { + explanation := "Read README.md first to confirm the current project structure before editing the config example." + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, + }}, + } + + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil) + if got != explanation { + t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want full explanation", got) + } +} + +func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) { + got := toolFeedbackArgsPreview(map[string]any{ + "path": "README.md", + "limit": 42, + }, 128) + want := "{\n \"limit\": 42,\n \"path\": \"README.md\"\n}" + if got != want { + t.Fatalf("toolFeedbackArgsPreview() = %q, want %q", got, want) + } +} + type picoInterleavedContentProvider struct { calls int } @@ -1432,6 +2082,43 @@ func (m *picoInterleavedContentProvider) GetDefaultModel() string { return "pico-interleaved-content-model" } +type picoDistinctToolCallContentProvider struct { + calls int +} + +func (m *picoDistinctToolCallContentProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "intermediate model text", + ToolCalls: []providers.ToolCall{{ + ID: "call_tool_limit_test", + Type: "function", + Name: "tool_limit_test_tool", + Arguments: map[string]any{"value": "x"}, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "final model text", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *picoDistinctToolCallContentProvider) GetDefaultModel() string { + return "pico-distinct-tool-call-content-model" +} + type toolLimitOnlyProvider struct{} func (m *toolLimitOnlyProvider) Chat( @@ -1909,6 +2596,75 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { } } +func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(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) + + deferred := true + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Discovery: config.ToolDiscoveryConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "github": { + Enabled: true, + Deferred: &deferred, + }, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + baseContext := bus.InboundContext{ + Channel: "whatsapp", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + } + + listResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/list mcp", + }) + if !strings.Contains(listResp, "- `github`") || !strings.Contains(listResp, "Deferred: yes") { + t.Fatalf("unexpected /list mcp reply: %q", listResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /list mcp, calls=%d", provider.calls) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/show mcp github", + }) + if showResp != "MCP server 'github' is configured but not connected" { + t.Fatalf("unexpected /show mcp reply: %q", showResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /show mcp, calls=%d", provider.calls) + } +} + func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { @@ -3286,6 +4042,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { select { case outbound := <-msgBus.OutboundChan(): + escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`) if outbound.Channel != "telegram" { t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram") } @@ -3296,7 +4053,22 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { t.Fatalf("unexpected tool feedback context: %+v", outbound.Context) } if !strings.Contains(outbound.Content, "`read_file`") { - t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content) + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check tool feedback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "\"path\":") { + t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content) + } + if !strings.Contains(outbound.Content, escapedHeartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } + if strings.Contains(outbound.Content, "Previous turn explanation") { + t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) } if outbound.AgentID != "main" { t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID) @@ -3312,6 +4084,313 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { } } +func TestProcessMessage_PersistsReasoningContentInSessionHistory(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &reasoningContentProvider{ + response: "final answer", + reasoningContent: "thinking trace", + } + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user1", + ChatID: "pico:test-session", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "final answer" { + t.Fatalf("processMessage() response = %q, want %q", response, "final answer") + } + + store := al.GetRegistry().GetDefaultAgent().Sessions + sessionKeys := store.ListSessions() + if len(sessionKeys) != 1 { + t.Fatalf("session keys = %v, want exactly 1 active session", sessionKeys) + } + history := store.GetHistory(sessionKeys[0]) + if len(history) < 2 { + t.Fatalf("session history len = %d, want at least 2", len(history)) + } + + last := history[len(history)-1] + if last.Role != "assistant" { + t.Fatalf("last message role = %q, want assistant", last.Role) + } + if last.Content != "final answer" { + t.Fatalf("last message content = %q, want %q", last.Content, "final answer") + } + if last.ReasoningContent != "thinking trace" { + t.Fatalf("last message reasoning_content = %q, want %q", last.ReasoningContent, "thinking trace") + } +} + +func TestProcessMessage_PersistsReasoningToolResponseAsSingleAssistantRecord(t *testing.T) { + tmpDir := t.TempDir() + inspectPath := filepath.Join(tmpDir, "inspect.txt") + if err := os.WriteFile(inspectPath, []byte("inspect me"), 0o644); err != nil { + t.Fatalf("WriteFile(inspectPath) error = %v", err) + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.ModelName = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 + + msgBus := bus.NewMessageBus() + provider := &reasoningVisibleToolProvider{filePath: inspectPath} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "DONE" { + t.Fatalf("processMessage() response = %q, want %q", response, "DONE") + } + + store := al.GetRegistry().GetDefaultAgent().Sessions + sessionKeys := store.ListSessions() + if len(sessionKeys) != 1 { + t.Fatalf("session keys = %v, want exactly 1 active session", sessionKeys) + } + + history := store.GetHistory(sessionKeys[0]) + if len(history) < 3 { + t.Fatalf("session history len = %d, want at least 3", len(history)) + } + + var assistantWithToolCall *providers.Message + for i := range history { + msg := history[i] + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + assistantWithToolCall = &msg + break + } + } + if assistantWithToolCall == nil { + t.Fatal("expected assistant history record with tool_calls") + } + if assistantWithToolCall.Content != "I'll inspect that file now." { + t.Fatalf("assistant content = %q, want %q", assistantWithToolCall.Content, "I'll inspect that file now.") + } + if assistantWithToolCall.ReasoningContent != "Read the file before answering." { + t.Fatalf("assistant reasoning_content = %q, want preserved", assistantWithToolCall.ReasoningContent) + } + if len(assistantWithToolCall.ToolCalls) != 1 { + t.Fatalf("assistant tool calls = %+v, want single read_file tool", assistantWithToolCall.ToolCalls) + } + if got := providers.NormalizeToolCall(assistantWithToolCall.ToolCalls[0]).Name; got != "read_file" { + t.Fatalf("assistant tool calls = %+v, want single read_file tool", assistantWithToolCall.ToolCalls) + } + + sessionDir := filepath.Join(tmpDir, "sessions") + entries, err := os.ReadDir(sessionDir) + if err != nil { + t.Fatalf("ReadDir(%q) error = %v", sessionDir, err) + } + + var jsonlPath string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + jsonlPath = filepath.Join(sessionDir, entry.Name()) + break + } + if jsonlPath == "" { + t.Fatal("expected session jsonl file to be created") + } + + data, err := os.ReadFile(jsonlPath) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", jsonlPath, err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 3 { + t.Fatalf("jsonl lines = %d, want at least 3", len(lines)) + } + + matchingRecords := 0 + for _, line := range lines { + var msg providers.Message + if err := json.Unmarshal([]byte(line), &msg); err != nil { + t.Fatalf("Unmarshal(jsonl line) error = %v", err) + } + if msg.Role != "assistant" { + continue + } + if msg.Content == "I'll inspect that file now." || msg.ReasoningContent == "Read the file before answering." { + matchingRecords++ + toolName := "" + if len(msg.ToolCalls) == 1 { + toolName = providers.NormalizeToolCall(msg.ToolCalls[0]).Name + } + if msg.Content != "I'll inspect that file now." || + msg.ReasoningContent != "Read the file before answering." || + len(msg.ToolCalls) != 1 || + toolName != "read_file" { + t.Fatalf("assistant jsonl record = %+v, want content+reasoning+tool_calls in one line", msg) + } + } + } + if matchingRecords != 1 { + t.Fatalf("matching assistant jsonl records = %d, want exactly 1 canonical assistant record", matchingRecords) + } +} + +func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackReasoningProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "check reasoning fallback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "DONE" { + t.Fatalf("processMessage() response = %q, want %q", response, "DONE") + } + + select { + case outbound := <-msgBus.OutboundChan(): + escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`) + if !strings.Contains(outbound.Content, "`read_file`") { + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check reasoning fallback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "\"path\":") { + t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content) + } + if !strings.Contains(outbound.Content, escapedHeartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } + if strings.Contains(outbound.Content, "Read README.md first") { + t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback without leaking reasoning") + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "discord") +} + +func assertToolFeedbackNotPublishedWhenDisabled(t *testing.T, channel string) { + t.Helper() + + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-"+channel+".txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: channel, + SenderID: "user-1", + ChatID: "chat-1", + Content: "check tool feedback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no outbound tool feedback for %s when disabled, got %+v", channel, outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "telegram") +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForFeishuWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "feishu") +} + func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = t.TempDir() @@ -3374,7 +4453,7 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t } msgBus := bus.NewMessageBus() - provider := &picoInterleavedContentProvider{} + provider := &picoDistinctToolCallContentProvider{} al := NewAgentLoop(cfg, msgBus, provider) agent := al.GetRegistry().GetDefaultAgent() @@ -3400,22 +4479,28 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t t.Fatalf("PublishInbound() error = %v", err) } - outputs := make([]string, 0, 2) + outputs := make([]bus.OutboundMessage, 0, 3) deadline := time.After(2 * time.Second) - for len(outputs) < 2 { + for len(outputs) < 3 { select { case outbound := <-msgBus.OutboundChan(): - outputs = append(outputs, outbound.Content) + outputs = append(outputs, outbound) case <-deadline: t.Fatalf("timed out waiting for pico outputs, got %v", outputs) } } - if outputs[0] != "intermediate model text" { - t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text") + if outputs[0].Content != "intermediate model text" { + t.Fatalf("first outbound content = %q, want %q", outputs[0].Content, "intermediate model text") } - if outputs[1] != "final model text" { - t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text") + if outputs[1].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls { + t.Fatalf("second outbound = %+v, want tool_calls message", outputs[1]) + } + if !strings.Contains(outputs[1].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") { + t.Fatalf("second outbound tool_calls = %q, want tool name", outputs[1].Context.Raw[metadataKeyToolCalls]) + } + if outputs[2].Content != "final model text" { + t.Fatalf("third outbound content = %q, want %q", outputs[2].Content, "final model text") } runCancel() @@ -3486,6 +4571,91 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) { } } +func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &picoInterleavedContentProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + agent.Tools.Register(&toolLimitTestTool{}) + + runCtx, runCancel := context.WithCancel(context.Background()) + defer runCancel() + + runDone := make(chan error, 1) + go func() { + runDone <- al.Run(runCtx) + }() + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user-1", + ChatID: "session-1", + Content: "run with tools", + }); err != nil { + t.Fatalf("PublishInbound() error = %v", err) + } + + outputs := make([]bus.OutboundMessage, 0, 3) + deadline := time.After(2 * time.Second) + for len(outputs) < 2 { + select { + case outbound := <-msgBus.OutboundChan(): + outputs = append(outputs, outbound) + case <-deadline: + t.Fatalf("timed out waiting for pico outputs, got %v", outputs) + } + } + + if outputs[0].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls { + t.Fatalf("first outbound = %+v, want tool_calls message", outputs[0]) + } + if outputs[0].Content != "" { + t.Fatalf("first outbound content = %q, want empty tool_calls content", outputs[0].Content) + } + if !strings.Contains(outputs[0].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") { + t.Fatalf("first outbound tool_calls = %q, want tool name", outputs[0].Context.Raw[metadataKeyToolCalls]) + } + if outputs[1].Content != "final model text" { + t.Fatalf("second outbound content = %q, want %q", outputs[1].Content, "final model text") + } + + runCancel() + select { + case err := <-runDone: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Run() to exit") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected extra pico output after tool feedback + final reply: %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -3958,3 +5128,258 @@ func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) { t.Fatalf("expected 2 calls for retry, got %d", provider.calls) } } + +func TestParallelMessageProcessing_DifferentSessionsProcessedConcurrently(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) + + // Track concurrent executions using a unique ID per turn + var mu sync.Mutex + activeTurns := make(map[string]bool) + maxConcurrent := 0 + turnCounter := 0 + var wg sync.WaitGroup + wg.Add(3) // Wait for 3 turns to complete + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 3, // Allow up to 3 concurrent turns + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + } + + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + // Create a slow mock provider that tracks concurrency + provider := &concurrentMockProvider{ + responseFunc: func(callID int) string { + mu.Lock() + turnCounter++ + turnID := fmt.Sprintf("turn-%d", turnCounter) + activeTurns[turnID] = true + currentActive := len(activeTurns) + if currentActive > maxConcurrent { + maxConcurrent = currentActive + } + mu.Unlock() + + // Simulate some processing time + time.Sleep(100 * time.Millisecond) + + mu.Lock() + delete(activeTurns, turnID) + mu.Unlock() + + wg.Done() + return fmt.Sprintf("Response %s", turnID) + }, + } + + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the agent loop + go func() { + if err := al.Run(ctx); err != nil { + t.Logf("Agent loop error: %v", err) + } + }() + + // Give the loop time to start + time.Sleep(50 * time.Millisecond) + + // Send 3 messages from different sessions + sessions := []string{"user1", "user2", "user3"} + for i, session := range sessions { + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: fmt.Sprintf("chat%d", i), + ChatType: "direct", + SenderID: session, + }, + Channel: "telegram", + ChatID: fmt.Sprintf("chat%d", i), + SenderID: session, + Content: fmt.Sprintf("Hello from %s", session), + } + if err := msgBus.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + } + + // Wait for all turns to complete with timeout + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // All turns completed successfully + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for turns to complete") + } + + // Verify that we had concurrent executions + mu.Lock() + defer mu.Unlock() + + if maxConcurrent < 2 { + t.Errorf("Expected at least 2 concurrent executions, got max %d", maxConcurrent) + } + + t.Logf("Maximum concurrent executions: %d", maxConcurrent) +} + +func TestParallelMessageProcessing_SameSessionProcessedSequentially(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) + + var mu sync.Mutex + turnIDs := make(map[string]bool) + var wg sync.WaitGroup + wg.Add(1) // Only 1 turn should be created for same session + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 3, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + } + + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + al := NewAgentLoop(cfg, msgBus, &concurrentMockProvider{ + responseFunc: func(callID int) string { + wg.Done() + return "ok" + }, + }) + defer al.Close() + + sub := al.SubscribeEvents(64) + + go func() { + for evt := range sub.C { + if evt.Kind == EventKindTurnStart { + mu.Lock() + turnIDs[evt.Meta.TurnID] = true + mu.Unlock() + } + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + if err := al.Run(ctx); err != nil { + t.Logf("Agent loop error: %v", err) + } + }() + + time.Sleep(50 * time.Millisecond) + + // Send 3 messages from the SAME session - only one turn should be created; + // subsequent messages should be enqueued to the steering queue and processed + // within the same turn (not as separate concurrent turns). + for i := 0; i < 3; i++ { + msg := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: fmt.Sprintf("Message %d", i+1), + } + if err := msgBus.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + } + + // Wait for turn to complete with timeout + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Turn completed successfully + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for turn to complete") + } + + mu.Lock() + defer mu.Unlock() + + // Only 1 turn ID should have been created — proving messages were + // serialized into a single turn rather than spawning concurrent turns. + if len(turnIDs) != 1 { + t.Errorf("Expected 1 turn (others queued to steering), got %d: %v", len(turnIDs), turnIDs) + } +} + +// concurrentMockProvider is a mock provider that allows tracking concurrency +type concurrentMockProvider struct { + responseFunc func(callID int) string +} + +func (p *concurrentMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + // Use an atomic counter to assign unique call IDs for concurrency tracking. + // This avoids relying on sessionKey derivation from message content, which + // is not deterministic across concurrent calls. + response := "Mock response" + if p.responseFunc != nil { + response = p.responseFunc(len(messages)) + } + + return &providers.LLMResponse{ + Content: response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (p *concurrentMockProvider) GetDefaultModel() string { + return "test-model" +} diff --git a/pkg/agent/agent_transcribe.go b/pkg/agent/agent_transcribe.go new file mode 100644 index 000000000..0ab328f36 --- /dev/null +++ b/pkg/agent/agent_transcribe.go @@ -0,0 +1,109 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { + if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { + return msg, false + } + + // Transcribe each audio media ref in order. + var transcriptions []string + var keptMedia []string + for _, ref := range msg.Media { + path, meta, err := al.mediaStore.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + keptMedia = append(keptMedia, ref) + continue + } + if !utils.IsAudioFile(meta.Filename, meta.ContentType) { + keptMedia = append(keptMedia, ref) + continue + } + result, err := al.transcriber.Transcribe(ctx, path) + if err != nil { + logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) + transcriptions = append(transcriptions, "") + keptMedia = append(keptMedia, ref) + continue + } + transcriptions = append(transcriptions, result.Text) + } + + if len(transcriptions) == 0 { + return msg, false + } + + al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) + + // Replace audio annotations sequentially with transcriptions. + idx := 0 + newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { + if idx >= len(transcriptions) { + return match + } + text := transcriptions[idx] + idx++ + if text == "" { + return match + } + return "[voice: " + text + "]" + }) + + // Append any remaining transcriptions not matched by an annotation. + for ; idx < len(transcriptions); idx++ { + if transcriptions[idx] != "" { + newContent += "\n[voice: " + transcriptions[idx] + "]" + } + } + + msg.Content = newContent + msg.Media = keptMedia + return msg, true +} + +func (al *AgentLoop) sendTranscriptionFeedback( + ctx context.Context, + channel, chatID, messageID string, + validTexts []string, +) { + if !al.cfg.Voice.EchoTranscription { + return + } + if al.channelManager == nil { + return + } + + var nonEmpty []string + for _, t := range validTexts { + if t != "" { + nonEmpty = append(nonEmpty, t) + } + } + + var feedbackMsg string + if len(nonEmpty) > 0 { + feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") + } else { + feedbackMsg = "No voice detected in the audio" + } + + err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ + Context: bus.NewOutboundContext(channel, chatID, messageID), + Content: feedbackMsg, + ReplyToMessageID: messageID, + }) + if err != nil { + logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) + } +} diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go new file mode 100644 index 000000000..bbfb3f2ae --- /dev/null +++ b/pkg/agent/agent_utils.go @@ -0,0 +1,598 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func outboundContextFromInbound( + inbound *bus.InboundContext, + channel, chatID, replyToMessageID string, +) bus.InboundContext { + if inbound == nil { + return bus.NewOutboundContext(channel, chatID, replyToMessageID) + } + + outboundCtx := *cloneInboundContext(inbound) + if outboundCtx.Channel == "" { + outboundCtx.Channel = channel + } + if outboundCtx.ChatID == "" { + outboundCtx.ChatID = chatID + } + if outboundCtx.ReplyToMessageID == "" { + outboundCtx.ReplyToMessageID = replyToMessageID + } + return outboundCtx +} + +func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope { + if scope == nil { + return nil + } + outboundScope := &bus.OutboundScope{ + Version: scope.Version, + AgentID: scope.AgentID, + Channel: scope.Channel, + Account: scope.Account, + } + if len(scope.Dimensions) > 0 { + outboundScope.Dimensions = append([]string(nil), scope.Dimensions...) + } + if len(scope.Values) > 0 { + outboundScope.Values = make(map[string]string, len(scope.Values)) + for key, value := range scope.Values { + outboundScope.Values[key] = value + } + } + return outboundScope +} + +func outboundTurnMetadata( + agentID, sessionKey string, + scope *session.SessionScope, +) (string, string, *bus.OutboundScope) { + return agentID, sessionKey, outboundScopeFromSessionScope(scope) +} + +func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { + agentID, sessionKey, scope := outboundTurnMetadata(ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope) + return bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: content, + } +} + +func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage { + msg := outboundMessageForTurn(ts, content) + if strings.TrimSpace(kind) == "" { + return msg + } + if msg.Context.Raw == nil { + msg.Context.Raw = make(map[string]string, 1) + } + msg.Context.Raw[metadataKeyMessageKind] = kind + return msg +} + +func latestUserContent(messages []providers.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role != "user" { + continue + } + if content := strings.TrimSpace(msg.Content); content != "" { + return content + } + } + return "" +} + +func toolFeedbackExplanationFromResponse( + response *providers.LLMResponse, + messages []providers.Message, +) string { + if response == nil { + return "" + } + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromToolCalls(response.ToolCalls) + } + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return explanation +} + +func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string { + for _, tc := range toolCalls { + if tc.ExtraContent == nil { + continue + } + if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return explanation + } + } + return "" +} + +func toolFeedbackExplanationForToolCall( + response *providers.LLMResponse, + toolCall providers.ToolCall, + messages []providers.Message, +) string { + if toolCall.ExtraContent != nil { + if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return explanation + } + } + if response == nil { + return toolFeedbackExplanationFromMessages(messages) + } + + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return explanation +} + +func toolFeedbackExplanationFromMessages(messages []providers.Message) string { + explanation := latestUserContent(messages) + if explanation != "" { + return utils.ToolFeedbackContinuationHint + ": " + explanation + } + return "" +} + +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) +} + +func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { + if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback { + return false + } + return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled() +} + +func cloneEventArguments(args map[string]any) map[string]any { + if len(args) == 0 { + return nil + } + + cloned := make(map[string]any, len(args)) + for k, v := range args { + cloned[k] = v + } + return cloned +} + +func hookDeniedToolContent(prefix, reason string) string { + if reason == "" { + return prefix + } + return prefix + ": " + reason +} + +func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) { + if turnCtx == nil { + return + } + + if inbound := turnCtx.Inbound; inbound != nil { + if inbound.Channel != "" { + fields["inbound_channel"] = inbound.Channel + } + if inbound.Account != "" { + fields["inbound_account"] = inbound.Account + } + if inbound.ChatID != "" { + fields["inbound_chat_id"] = inbound.ChatID + } + if inbound.ChatType != "" { + fields["inbound_chat_type"] = inbound.ChatType + } + if inbound.TopicID != "" { + fields["inbound_topic_id"] = inbound.TopicID + } + if inbound.SpaceType != "" { + fields["inbound_space_type"] = inbound.SpaceType + } + if inbound.SpaceID != "" { + fields["inbound_space_id"] = inbound.SpaceID + } + if inbound.SenderID != "" { + fields["inbound_sender_id"] = inbound.SenderID + } + if inbound.Mentioned { + fields["inbound_mentioned"] = true + } + } + + if route := turnCtx.Route; route != nil { + if route.AgentID != "" { + fields["route_agent_id"] = route.AgentID + } + if route.Channel != "" { + fields["route_channel"] = route.Channel + } + if route.AccountID != "" { + fields["route_account_id"] = route.AccountID + } + if route.MatchedBy != "" { + fields["route_matched_by"] = route.MatchedBy + } + if len(route.SessionPolicy.Dimensions) > 0 { + fields["route_dimensions"] = strings.Join(route.SessionPolicy.Dimensions, ",") + } + if count := len(route.SessionPolicy.IdentityLinks); count > 0 { + fields["route_identity_link_count"] = count + } + } + + if scope := turnCtx.Scope; scope != nil { + if scope.Version > 0 { + fields["scope_version"] = scope.Version + } + if scope.AgentID != "" { + fields["scope_agent_id"] = scope.AgentID + } + if scope.Channel != "" { + fields["scope_channel"] = scope.Channel + } + if scope.Account != "" { + fields["scope_account"] = scope.Account + } + if len(scope.Dimensions) > 0 { + fields["scope_dimensions"] = strings.Join(scope.Dimensions, ",") + } + for dim, value := range scope.Values { + if dim == "" || value == "" { + continue + } + fields["scope_"+dim] = value + } + } +} + +func inferMediaType(filename, contentType string) string { + ct := strings.ToLower(contentType) + fn := strings.ToLower(filename) + + if strings.HasPrefix(ct, "image/") { + return "image" + } + if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { + return "audio" + } + if strings.HasPrefix(ct, "video/") { + return "video" + } + + // Fallback: infer from extension + ext := filepath.Ext(fn) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + } + + return "file" +} + +func normalizedInboundContext(msg bus.InboundMessage) bus.InboundContext { + return bus.NormalizeInboundMessage(msg).Context +} + +func resolveScopeKey(routeSessionKey, msgSessionKey string) string { + if isExplicitSessionKey(msgSessionKey) { + return msgSessionKey + } + return routeSessionKey +} + +func isExplicitSessionKey(sessionKey string) bool { + return session.IsExplicitSessionKey(sessionKey) +} + +func buildSessionAliases(canonicalKey string, keys ...string) []string { + if len(keys) == 0 { + return nil + } + aliases := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + canonicalKey = strings.TrimSpace(canonicalKey) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" || key == canonicalKey { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + aliases = append(aliases, key) + } + if len(aliases) == 0 { + return nil + } + return aliases +} + +func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) { + if key == "" || scope == nil { + return + } + metaStore, ok := store.(interface { + EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string) + }) + if !ok { + return + } + metaStore.EnsureSessionMetadata(key, scope, aliases) +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func formatMessagesForLog(messages []providers.Message) string { + if len(messages) == 0 { + return "[]" + } + + var sb strings.Builder + sb.WriteString("[\n") + for i, msg := range messages { + fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) + if len(msg.ToolCalls) > 0 { + sb.WriteString(" ToolCalls:\n") + for _, tc := range msg.ToolCalls { + fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) + if tc.Function != nil { + fmt.Fprintf( + &sb, + " Arguments: %s\n", + utils.Truncate(tc.Function.Arguments, 200), + ) + } + } + } + if msg.Content != "" { + content := utils.Truncate(msg.Content, 200) + fmt.Fprintf(&sb, " Content: %s\n", content) + } + if msg.ToolCallID != "" { + fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) + } + sb.WriteString("\n") + } + sb.WriteString("]") + return sb.String() +} + +func formatToolsForLog(toolDefs []providers.ToolDefinition) string { + if len(toolDefs) == 0 { + return "[]" + } + + var sb strings.Builder + sb.WriteString("[\n") + for i, tool := range toolDefs { + fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) + fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) + if len(tool.Function.Parameters) > 0 { + fmt.Fprintf( + &sb, + " Parameters: %s\n", + utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), + ) + } + } + sb.WriteString("]") + return sb.String() +} + +func activeSkillNames(agent *AgentInstance, opts processOptions) []string { + if agent == nil { + return nil + } + + combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills)) + combined = append(combined, agent.SkillsFilter...) + combined = append(combined, opts.ForcedSkills...) + if len(combined) == 0 { + return nil + } + + var resolved []string + seen := make(map[string]struct{}, len(combined)) + for _, name := range combined { + name = strings.TrimSpace(name) + if name == "" { + continue + } + if agent.ContextBuilder != nil { + if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok { + name = canonical + } + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + resolved = append(resolved, name) + } + + return resolved +} + +func sideQuestionResponseContent(response *providers.LLMResponse) string { + if response == nil { + return "" + } + if strings.TrimSpace(response.Content) != "" { + return response.Content + } + return responseReasoningContent(response) +} + +func responseReasoningContent(response *providers.LLMResponse) string { + if response == nil { + return "" + } + if strings.TrimSpace(response.Reasoning) != "" { + return response.Reasoning + } + if strings.TrimSpace(response.ReasoningContent) != "" { + return response.ReasoningContent + } + return "" +} + +func shallowCloneLLMOptions(opts map[string]any) map[string]any { + clone := make(map[string]any, len(opts)) + maps.Copy(clone, opts) + return clone +} + +func hasMediaRefs(messages []providers.Message) bool { + for _, msg := range messages { + if len(msg.Media) > 0 { + return true + } + } + return false +} + +func sideQuestionModelName(agent *AgentInstance, usedLight bool) string { + if usedLight && len(agent.LightCandidates) > 0 { + // Use the first light candidate's model + return agent.LightCandidates[0].Model + } + return agent.Model +} + +func modelNameFromIdentityKey(identityKey string) string { + if identityKey == "" { + return "" + } + parts := strings.SplitN(identityKey, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return identityKey +} + +func closeProviderIfStateful(provider providers.LLMProvider) { + if stateful, ok := provider.(providers.StatefulProvider); ok { + stateful.Close() + } +} + +func makePendingTurnID(sessionKey string, seq uint64) string { + return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq) +} + +func commandsUnavailableSkillMessage() string { + return "Skill selection is unavailable in the current context." +} + +func buildUseCommandHelp(agent *AgentInstance) string { + if agent == nil || agent.ContextBuilder == nil { + return "Usage: /use [message]" + } + + names := agent.ContextBuilder.ListSkillNames() + if len(names) == 0 { + return "Usage: /use [message]\nNo installed skills found." + } + + return fmt.Sprintf( + "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.", + strings.Join(names, "\n- "), + ) +} + +func mapCommandError(result commands.ExecuteResult) string { + if result.Command == "" { + return fmt.Sprintf("Failed to execute command: %v", result.Err) + } + return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) +} + +func isNativeSearchProvider(p providers.LLMProvider) bool { + if ns, ok := p.(providers.NativeSearchCapable); ok { + return ns.SupportsNativeSearch() + } + return false +} + +func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition { + result := make([]providers.ToolDefinition, 0, len(tools)) + for _, t := range tools { + if strings.EqualFold(t.Function.Name, "web_search") { + continue + } + result = append(result, t) + } + return result +} + +func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) { + if registry == nil { + return nil, false + } + // Get any agent to access the provider + defaultAgent := registry.GetDefaultAgent() + if defaultAgent == nil { + return nil, false + } + return defaultAgent.Provider, true +} diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ecf5da3dc..ecde7c33e 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -1,6 +1,7 @@ package agent import ( + "context" "errors" "fmt" "io/fs" @@ -11,6 +12,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -20,12 +22,11 @@ import ( ) type ContextBuilder struct { - workspace string - skillsLoader *skills.SkillsLoader - memory *MemoryStore - toolDiscoveryBM25 bool - toolDiscoveryRegex bool - splitOnMarker bool + workspace string + skillsLoader *skills.SkillsLoader + memory *MemoryStore + splitOnMarker bool + promptRegistry *PromptRegistry // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -47,8 +48,16 @@ type ContextBuilder struct { } func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder { - cb.toolDiscoveryBM25 = useBM25 - cb.toolDiscoveryRegex = useRegex + if useBM25 || useRegex { + if err := cb.RegisterPromptContributor(toolDiscoveryPromptContributor{ + useBM25: useBM25, + useRegex: useRegex, + }); err != nil { + logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{ + "error": err.Error(), + }) + } + } return cb } @@ -72,15 +81,38 @@ func NewContextBuilder(workspace string) *ContextBuilder { globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") return &ContextBuilder{ - workspace: workspace, - skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), - memory: NewMemoryStore(workspace), + workspace: workspace, + skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), + memory: NewMemoryStore(workspace), + promptRegistry: NewPromptRegistry(), } } +func (cb *ContextBuilder) RegisterPromptSource(desc PromptSourceDescriptor) error { + err := cb.promptRegistryOrDefault().RegisterSource(desc) + if err == nil { + cb.InvalidateCache() + } + return err +} + +func (cb *ContextBuilder) RegisterPromptContributor(contributor PromptContributor) error { + err := cb.promptRegistryOrDefault().RegisterContributor(contributor) + if err == nil { + cb.InvalidateCache() + } + return err +} + +func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry { + if cb.promptRegistry == nil { + cb.promptRegistry = NewPromptRegistry() + } + return cb.promptRegistry +} + func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) - toolDiscovery := cb.getDiscoveryRule() version := config.FormatVersion() return fmt.Sprintf( @@ -102,22 +134,20 @@ Your workspace is at: %s 3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md -4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content. - -%s`, - version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) +4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, + version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) } -func (cb *ContextBuilder) getDiscoveryRule() string { - if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex { +func formatToolDiscoveryRule(useBM25, useRegex bool) string { + if !useBM25 && !useRegex { return "" } var toolNames []string - if cb.toolDiscoveryBM25 { + if useBM25 { toolNames = append(toolNames, `"tool_search_tool_bm25"`) } - if cb.toolDiscoveryRegex { + if useRegex { toolNames = append(toolNames, `"tool_search_tool_regex"`) } @@ -128,43 +158,103 @@ func (cb *ContextBuilder) getDiscoveryRule() string { } func (cb *ContextBuilder) BuildSystemPrompt() string { - parts := []string{} + return renderPromptPartsLegacy(cb.BuildSystemPromptParts()) +} + +func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart { + stack := NewPromptStack(cb.promptRegistryOrDefault()) + add := func(part PromptPart) { + if err := stack.Add(part); err != nil { + logger.WarnCF("agent", "Skipping invalid prompt part", map[string]any{ + "id": part.ID, + "layer": part.Layer, + "slot": part.Slot, + "source": part.Source.ID, + "error": err.Error(), + }) + } + } // Core identity section - parts = append(parts, cb.getIdentity()) + add(PromptPart{ + ID: "kernel.identity", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "identity"}, + Title: "picoclaw identity", + Content: cb.getIdentity(), + Stable: true, + Cache: PromptCacheEphemeral, + }) // Bootstrap files bootstrapContent := cb.LoadBootstrapFiles() if bootstrapContent != "" { - parts = append(parts, bootstrapContent) + add(PromptPart{ + ID: "instruction.workspace", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceWorkspace, Name: "workspace"}, + Title: "workspace instructions", + Content: bootstrapContent, + Stable: true, + Cache: PromptCacheEphemeral, + }) } // Skills - show summary, AI can read full content with read_file tool skillsSummary := cb.skillsLoader.BuildSkillsSummary() if skillsSummary != "" { - parts = append(parts, fmt.Sprintf(`# Skills + add(PromptPart{ + ID: "capability.skill_catalog", + Layer: PromptLayerCapability, + Slot: PromptSlotSkillCatalog, + Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"}, + Title: "skill catalog", + Content: fmt.Sprintf(`# Skills The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. -%s`, skillsSummary)) +%s`, skillsSummary), + Stable: true, + Cache: PromptCacheEphemeral, + }) } // Memory context memoryContext := cb.memory.GetMemoryContext() if memoryContext != "" { - parts = append(parts, "# Memory\n\n"+memoryContext) + add(PromptPart{ + ID: "context.memory", + Layer: PromptLayerContext, + Slot: PromptSlotMemory, + Source: PromptSource{ID: PromptSourceMemory, Name: "memory:workspace"}, + Title: "memory", + Content: "# Memory\n\n" + memoryContext, + Stable: true, + Cache: PromptCacheEphemeral, + }) } // Multi-Message Sending (if enabled) if cb.splitOnMarker { - parts = append(parts, `# MULTI-MESSAGE OUTPUT + add(PromptPart{ + ID: "context.output_policy.split_on_marker", + Layer: PromptLayerContext, + Slot: PromptSlotOutput, + Source: PromptSource{ID: PromptSourceOutputPolicy, Name: "split_on_marker"}, + Title: "multi-message output policy", + Content: `# MULTI-MESSAGE OUTPUT You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3 -Each part separated by the marker will be sent as an independent message.`) +Each part separated by the marker will be sent as an independent message.`, + Stable: true, + Cache: PromptCacheEphemeral, + }) } - // Join with "---" separator - return strings.Join(parts, "\n\n---\n\n") + stack.Seal() + return stack.Parts() } // BuildSystemPromptWithCache returns the cached system prompt if available @@ -210,6 +300,49 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { return prompt } +// EstimateSystemTokens estimates the token count of the full system message +// that would be sent to the LLM, mirroring the composition logic in BuildMessages. +// It includes: static prompt, dynamic context, active skills, and summary with +// wrapping prefixes and separators. This avoids needing all per-request parameters +// that BuildMessages requires (media, channel, chatID, sender, etc.). +func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int { + staticPrompt := cb.BuildSystemPromptWithCache() + + // Dynamic context is small and varies per request; use a representative estimate. + // Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info. + const dynamicContextChars = 300 + + totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars + + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { + totalChars += utf8.RuneCountInString(skillsText) + totalChars += 7 // separator \n\n---\n\n + } + + if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), PromptBuildRequest{ + Summary: summary, + ActiveSkills: append([]string(nil), activeSkills...), + }); err == nil { + for _, part := range contributedParts { + if strings.TrimSpace(part.Content) == "" { + continue + } + totalChars += utf8.RuneCountInString(part.Content) + totalChars += 7 // separator + } + } + + if summary != "" { + // Matches the CONTEXT_SUMMARY: prefix added in BuildMessages + const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " + + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n" + totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary) + totalChars += 7 // separator + } + + return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens +} + // InvalidateCache clears the cached system prompt. // Normally not needed because the cache auto-invalidates via mtime checks, // but this is useful for tests or explicit reload commands. @@ -517,6 +650,20 @@ func (cb *ContextBuilder) BuildMessages( channel, chatID, senderID, senderDisplayName string, activeSkills ...string, ) []providers.Message { + return cb.BuildMessagesFromPrompt(PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: currentMessage, + Media: media, + Channel: channel, + ChatID: chatID, + SenderID: senderID, + SenderDisplayName: senderDisplayName, + ActiveSkills: append([]string(nil), activeSkills...), + }) +} + +func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message { messages := []providers.Message{} // The static part (identity, bootstrap, skills, memory) is cached locally to @@ -531,7 +678,7 @@ func (cb *ContextBuilder) BuildMessages( staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName) + dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can @@ -542,25 +689,77 @@ func (cb *ContextBuilder) BuildMessages( // cache-aware adapters (Anthropic) can set per-block cache_control. // The static block is marked "ephemeral" — its prefix hash is stable // across requests, enabling LLM-side KV cache reuse. - stringParts := []string{staticPrompt, dynamicCtx} + stringParts := []string{staticPrompt} contentBlocks := []providers.ContentBlock{ - {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, - {Type: "text", Text: dynamicCtx}, + promptContentBlock(PromptPart{ + ID: "kernel.static", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel, Name: "static"}, + Content: staticPrompt, + }, &providers.CacheControl{Type: "ephemeral"}), } - if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { - stringParts = append(stringParts, skillsText) - contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText}) + promptParts := append([]PromptPart(nil), req.Overlays...) + promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...) + if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil { + logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{ + "error": err.Error(), + }) + } else { + promptParts = append(promptParts, contributedParts...) } - if summary != "" { - summaryText := fmt.Sprintf( - "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ - "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", - summary) - stringParts = append(stringParts, summaryText) - contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) + if len(promptParts) > 0 { + for _, overlay := range sortPromptParts(promptParts) { + if strings.TrimSpace(overlay.Content) == "" { + continue + } + if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil { + logger.WarnCF("agent", "Skipping invalid prompt overlay", map[string]any{ + "id": overlay.ID, + "layer": overlay.Layer, + "slot": overlay.Slot, + "source": overlay.Source.ID, + "error": err.Error(), + }) + continue + } + stringParts = append(stringParts, overlay.Content) + contentBlocks = append(contentBlocks, promptContentBlock(overlay, nil)) + } + } + + runtimePart := PromptPart{ + ID: "context.runtime", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"}, + Title: "runtime context", + Content: dynamicCtx, + Stable: false, + Cache: PromptCacheNone, + } + stringParts = append(stringParts, dynamicCtx) + contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil)) + + if req.Summary != "" { + summaryPart := PromptPart{ + ID: "context.summary", + Layer: PromptLayerContext, + Slot: PromptSlotSummary, + Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"}, + Title: "context summary", + Content: fmt.Sprintf( + "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", + req.Summary), + Stable: false, + Cache: PromptCacheNone, + } + stringParts = append(stringParts, summaryPart.Content) + contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil)) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") @@ -577,7 +776,8 @@ func (cb *ContextBuilder) BuildMessages( "static_chars": len(staticPrompt), "dynamic_chars": len(dynamicCtx), "total_chars": len(fullSystemPrompt), - "has_summary": summary != "", + "has_summary": req.Summary != "", + "overlays": len(req.Overlays), "cached": isCached, }) @@ -588,7 +788,7 @@ func (cb *ContextBuilder) BuildMessages( "preview": preview, }) - history = sanitizeHistoryForProvider(history) + history := sanitizeHistoryForProvider(req.History) // Single system message containing all context — compatible with all providers. // SystemParts enables cache-aware adapters to set per-block cache_control; @@ -605,15 +805,8 @@ func (cb *ContextBuilder) BuildMessages( // Add current user message. Media-only turns must still be preserved so // multimodal providers receive the uploaded image even when the user sends // no accompanying text. - if strings.TrimSpace(currentMessage) != "" || len(media) > 0 { - msg := providers.Message{ - Role: "user", - Content: currentMessage, - } - if len(media) > 0 { - msg.Media = append([]string(nil), media...) - } - messages = append(messages, msg) + if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 { + messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media)) } return messages @@ -839,6 +1032,26 @@ The following skills are active for this request. Follow them when relevant. %s`, content) } +func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart { + skillsText := cb.buildActiveSkillsContext(skillNames) + if strings.TrimSpace(skillsText) == "" { + return nil + } + + return []PromptPart{ + { + ID: "capability.active_skills", + Layer: PromptLayerCapability, + Slot: PromptSlotActiveSkill, + Source: PromptSource{ID: PromptSourceActiveSkills, Name: "skill:active"}, + Title: "active skills", + Content: skillsText, + Stable: false, + Cache: PromptCacheNone, + }, + } +} + func (cb *ContextBuilder) ListSkillNames() []string { if cb.skillsLoader == nil { return nil diff --git a/pkg/agent/context_usage.go b/pkg/agent/context_usage.go new file mode 100644 index 000000000..39d4f3dee --- /dev/null +++ b/pkg/agent/context_usage.go @@ -0,0 +1,78 @@ +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/bus" +) + +// computeContextUsage estimates current context window consumption for the +// given agent and session. Includes history, system prompt (with dynamic context, +// summary, and skills — mirroring BuildMessages composition), and tool definitions. +// The output reserve (MaxTokens) is not counted as "used" but reduces the +// effective budget, matching isOverContextBudget's compression trigger: +// +// compress when: history + system + tools + maxTokens > contextWindow +// equivalent to: history + system + tools > contextWindow - maxTokens +// +// Returns nil when the agent or session is unavailable. +func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage { + if agent == nil || agent.Sessions == nil { + return nil + } + contextWindow := agent.ContextWindow + if contextWindow <= 0 { + return nil + } + + // History tokens + history := agent.Sessions.GetHistory(sessionKey) + historyTokens := 0 + for _, m := range history { + historyTokens += EstimateMessageTokens(m) + } + + // System message tokens: uses EstimateSystemTokens which mirrors + // the full system message composition in BuildMessages (static prompt, + // dynamic context, active skills, summary with wrapping prefix). + systemTokens := 0 + if agent.ContextBuilder != nil { + summary := agent.Sessions.GetSummary(sessionKey) + // Pass nil for active skills: skills are only injected when the user + // explicitly activates them via /use, which is rare. Using nil matches + // the common case and avoids over-counting all installed skills. + systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil) + } + + // Tool definition tokens + toolTokens := 0 + if agent.Tools != nil { + toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs()) + } + + // Used = history + system (includes summary) + tools + usedTokens := historyTokens + systemTokens + toolTokens + + // Effective budget = contextWindow minus output reserve (maxTokens) + effectiveWindow := contextWindow - agent.MaxTokens + if effectiveWindow < 0 { + effectiveWindow = contextWindow + } + + // compressAt = effectiveWindow: aligns with isOverContextBudget's + // proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow). + compressAt := effectiveWindow + + usedPercent := 0 + if compressAt > 0 { + usedPercent = usedTokens * 100 / compressAt + } + if usedPercent > 100 { + usedPercent = 100 + } + + return &bus.ContextUsage{ + UsedTokens: usedTokens, + TotalTokens: contextWindow, + CompressAtTokens: compressAt, + UsedPercent: usedPercent, + } +} diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index 687e54532..9cc3e6951 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "reflect" "sort" "sync" "time" @@ -325,6 +326,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if next != nil { + next = hm.applyBeforeLLMControls(reg.Name, current, next) current = next } case HookActionAbortTurn, HookActionHardAbort: @@ -367,6 +369,84 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL return current, HookDecision{Action: HookActionContinue} } +func (hm *HookManager) applyBeforeLLMControls( + hookName string, + current *LLMHookRequest, + next *LLMHookRequest, +) *LLMHookRequest { + if next == nil || current == nil { + return next + } + if !llmHookSystemMessagesUnchanged(current.Messages, next.Messages) { + logger.WarnCF("hooks", "Hook attempted to modify system prompt; preserving original messages", map[string]any{ + "hook": hookName, + }) + next.Messages = cloneProviderMessages(current.Messages) + } + if !llmHookToolDefinitionsUnchanged(current.Tools, next.Tools) { + logger.WarnCF("hooks", "Hook attempted to modify tool definitions; preserving original tools", map[string]any{ + "hook": hookName, + }) + next.Tools = cloneToolDefinitions(current.Tools) + } + return next +} + +func llmHookSystemMessagesUnchanged(before, after []providers.Message) bool { + beforeSystem := systemMessageFingerprints(before) + afterSystem := systemMessageFingerprints(after) + return reflect.DeepEqual(beforeSystem, afterSystem) +} + +type systemMessageFingerprint struct { + Index int + Message providers.Message +} + +func systemMessageFingerprints(messages []providers.Message) []systemMessageFingerprint { + var fingerprints []systemMessageFingerprint + for i, msg := range messages { + if msg.Role != "system" { + continue + } + msg = providerVisibleMessage(msg) + fingerprints = append(fingerprints, systemMessageFingerprint{ + Index: i, + Message: cloneProviderMessages([]providers.Message{msg})[0], + }) + } + return fingerprints +} + +func llmHookToolDefinitionsUnchanged(before, after []providers.ToolDefinition) bool { + return reflect.DeepEqual(providerVisibleToolDefinitions(before), providerVisibleToolDefinitions(after)) +} + +func providerVisibleMessage(msg providers.Message) providers.Message { + msg.PromptLayer = "" + msg.PromptSlot = "" + msg.PromptSource = "" + if len(msg.SystemParts) > 0 { + msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...) + for i := range msg.SystemParts { + msg.SystemParts[i].PromptLayer = "" + msg.SystemParts[i].PromptSlot = "" + msg.SystemParts[i].PromptSource = "" + } + } + return msg +} + +func providerVisibleToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition { + cloned := cloneToolDefinitions(defs) + for i := range cloned { + cloned[i].PromptLayer = "" + cloned[i].PromptSlot = "" + cloned[i].PromptSource = "" + } + return cloned +} + func (hm *HookManager) BeforeTool( ctx context.Context, call *ToolCallHookRequest, @@ -788,7 +868,7 @@ func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse { func cloneStringAnyMap(src map[string]any) map[string]any { if len(src) == 0 { - return nil + return map[string]any{} } cloned := make(map[string]any, len(src)) diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index cf0d03c03..aa52bf2d5 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -2,8 +2,10 @@ package agent import ( "context" + "encoding/json" "errors" "os" + "strings" "sync" "testing" "time" @@ -111,6 +113,8 @@ func (p *llmHookTestProvider) GetDefaultModel() string { type llmObserverHook struct { eventCh chan Event lastInbound *bus.InboundContext + lastRoute *routing.ResolvedRoute + lastScope *session.SessionScope } func (h *llmObserverHook) OnEvent(ctx context.Context, evt Event) error { @@ -129,6 +133,8 @@ func (h *llmObserverHook) BeforeLLM( ) (*LLMHookRequest, HookDecision, error) { if req.Context != nil { h.lastInbound = cloneInboundContext(req.Context.Inbound) + h.lastRoute = cloneResolvedRoute(req.Context.Route) + h.lastScope = session.CloneScope(req.Context.Scope) } next := req.Clone() next.Model = "hook-model" @@ -144,6 +150,268 @@ func (h *llmObserverHook) AfterLLM( return next, HookDecision{Action: HookActionModify}, nil } +type llmSystemRewriteHook struct{} + +func (h *llmSystemRewriteHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Model = "changed-model" + next.Messages[0].Content = "rewritten system" + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmSystemRewriteHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +type llmUserAppendHook struct{} + +func (h *llmUserAppendHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "extra user context"}) + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmUserAppendHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +type llmJSONRoundTripUserAppendHook struct{} + +type jsonRoundTripLLMHookRequest struct { + Model string `json:"model"` + Messages []providers.Message `json:"messages,omitempty"` + Tools []providers.ToolDefinition `json:"tools,omitempty"` +} + +func (h *llmJSONRoundTripUserAppendHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + payload := jsonRoundTripLLMHookRequest{ + Model: req.Model, + Messages: req.Messages, + Tools: req.Tools, + } + data, err := json.Marshal(payload) + if err != nil { + return nil, HookDecision{}, err + } + var decoded jsonRoundTripLLMHookRequest + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, HookDecision{}, err + } + next := req.Clone() + next.Model = decoded.Model + next.Messages = decoded.Messages + next.Tools = decoded.Tools + next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "json extra user context"}) + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmJSONRoundTripUserAppendHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +type llmToolRewriteHook struct{} + +func (h *llmToolRewriteHook) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision, error) { + next := req.Clone() + next.Model = "changed-model" + next.Tools[0].Function.Description = "rewritten tool" + next.Tools = append(next.Tools, providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "hook_tool", + Description: "hook tool", + Parameters: map[string]any{"type": "object"}, + }, + PromptLayer: string(PromptLayerCapability), + PromptSlot: string(PromptSlotTooling), + PromptSource: "hook:test", + }) + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *llmToolRewriteHook) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision, error) { + return resp.Clone(), HookDecision{Action: HookActionContinue}, nil +} + +func TestHookManager_BeforeLLMControlsSystemPromptMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("rewrite-system", &llmSystemRewriteHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "original-model", + Messages: []providers.Message{ + { + Role: "system", + Content: "original system", + SystemParts: []providers.ContentBlock{ + {Type: "text", Text: "original system"}, + }, + }, + {Role: "user", Content: "hello"}, + }, + } + + got, decision := hm.BeforeLLM(context.Background(), req) + if decision.normalizedAction() != HookActionContinue { + t.Fatalf("decision = %v, want continue", decision) + } + if got.Model != "changed-model" { + t.Fatalf("model = %q, want changed-model", got.Model) + } + if got.Messages[0].Content != "original system" { + t.Fatalf("system content = %q, want original system", got.Messages[0].Content) + } + if got.Messages[1].Content != "hello" { + t.Fatalf("user content = %q, want hello", got.Messages[1].Content) + } +} + +func TestHookManager_BeforeLLMAllowsNonSystemMessageMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("append-user", &llmUserAppendHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "model", + Messages: []providers.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "hello"}, + }, + } + + got, _ := hm.BeforeLLM(context.Background(), req) + if len(got.Messages) != 3 { + t.Fatalf("messages len = %d, want 3", len(got.Messages)) + } + if got.Messages[2].Role != "user" || got.Messages[2].Content != "extra user context" { + t.Fatalf("appended message = %#v, want extra user context", got.Messages[2]) + } +} + +func TestHookManager_BeforeLLMAllowsJSONRoundTripNonSystemMessageMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("json-append-user", &llmJSONRoundTripUserAppendHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "model", + Messages: []providers.Message{ + { + Role: "system", + Content: "system", + PromptLayer: string(PromptLayerKernel), + PromptSlot: string(PromptSlotIdentity), + PromptSource: string(PromptSourceKernel), + SystemParts: []providers.ContentBlock{ + { + Type: "text", + Text: "system", + CacheControl: &providers.CacheControl{Type: "ephemeral"}, + PromptLayer: string(PromptLayerKernel), + PromptSlot: string(PromptSlotIdentity), + PromptSource: string(PromptSourceKernel), + }, + }, + }, + {Role: "user", Content: "hello"}, + }, + Tools: []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "mcp_github_create_issue", + Description: "create issue", + Parameters: map[string]any{"type": "object"}, + }, + PromptLayer: string(PromptLayerCapability), + PromptSlot: string(PromptSlotMCP), + PromptSource: "mcp:github", + }, + }, + } + + got, _ := hm.BeforeLLM(context.Background(), req) + if len(got.Messages) != 3 { + t.Fatalf("messages len = %d, want 3", len(got.Messages)) + } + if got.Messages[2].Role != "user" || got.Messages[2].Content != "json extra user context" { + t.Fatalf("appended message = %#v, want json extra user context", got.Messages[2]) + } +} + +func TestHookManager_BeforeLLMControlsToolDefinitionMutation(t *testing.T) { + hm := NewHookManager(nil) + if err := hm.Mount(NamedHook("rewrite-tool", &llmToolRewriteHook{})); err != nil { + t.Fatalf("Mount() error = %v", err) + } + + req := &LLMHookRequest{ + Model: "original-model", + Messages: []providers.Message{ + {Role: "system", Content: "system"}, + {Role: "user", Content: "hello"}, + }, + Tools: []providers.ToolDefinition{ + { + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "mcp_github_create_issue", + Description: "create issue", + Parameters: map[string]any{"type": "object"}, + }, + PromptLayer: string(PromptLayerCapability), + PromptSlot: string(PromptSlotMCP), + PromptSource: "mcp:github", + }, + }, + } + + got, decision := hm.BeforeLLM(context.Background(), req) + if decision.normalizedAction() != HookActionContinue { + t.Fatalf("decision = %v, want continue", decision) + } + if got.Model != "changed-model" { + t.Fatalf("model = %q, want changed-model", got.Model) + } + if len(got.Tools) != 1 { + t.Fatalf("tools len = %d, want original 1", len(got.Tools)) + } + if got.Tools[0].Function.Description != "create issue" { + t.Fatalf("tool description = %q, want original", got.Tools[0].Function.Description) + } + if got.Tools[0].PromptSource != "mcp:github" || got.Tools[0].PromptSlot != string(PromptSlotMCP) { + t.Fatalf("tool prompt metadata = %#v, want original mcp metadata", got.Tools[0]) + } +} + func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { provider := &llmHookTestProvider{} al, agent, cleanup := newHookTestLoop(t, provider) @@ -230,6 +498,91 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { } } +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)} + if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + response, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + }, + Content: "/btw hello", + }, agent, &processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "session-1", + InboundContext: &bus.InboundContext{ + Channel: "cli", + ChatID: "direct", + ChatType: "direct", + SenderID: "hook-user", + }, + RouteResult: &routing.ResolvedRoute{ + AgentID: "main", + Channel: "cli", + AccountID: routing.DefaultAccountID, + SessionPolicy: routing.SessionPolicy{ + Dimensions: []string{"sender"}, + }, + MatchedBy: "default", + }, + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "cli", + Account: routing.DefaultAccountID, + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "hook-user", + }, + }, + UserMessage: "/btw hello", + }, + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + SenderID: "hook-user", + SenderDisplayName: "Hook User", + }) + if !handled { + t.Fatal("expected /btw command to be handled") + } + if response != "hooked content" { + t.Fatalf("expected hooked content, got %q", response) + } + + provider.mu.Lock() + lastModel := provider.lastModel + provider.mu.Unlock() + if lastModel != "hook-model" { + t.Fatalf("expected model hook-model, got %q", lastModel) + } + if hook.lastInbound == nil { + t.Fatal("expected hook to receive inbound context") + } + if hook.lastInbound.Channel != "cli" || hook.lastInbound.SenderID != "hook-user" { + t.Fatalf("hook inbound context = %+v", hook.lastInbound) + } + if hook.lastInbound.ChatID != "direct" { + t.Fatalf("hook inbound chat ID = %q, want direct", hook.lastInbound.ChatID) + } + if hook.lastRoute == nil || hook.lastRoute.AgentID != "main" { + t.Fatalf("expected hook route context for /btw, got %+v", hook.lastRoute) + } + if hook.lastScope == nil || hook.lastScope.Values["sender"] != "hook-user" { + t.Fatalf("expected hook session scope for /btw, got %+v", hook.lastScope) + } +} + type toolHookProvider struct { mu sync.Mutex calls int @@ -314,6 +667,24 @@ func (h *toolRewriteHook) AfterTool( return next, HookDecision{Action: HookActionModify}, nil } +type toolRenameHook struct{} + +func (h *toolRenameHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + next := call.Clone() + next.Tool = "echo_text_rewritten" + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *toolRenameHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + return result.Clone(), HookDecision{Action: HookActionContinue}, nil +} + func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { provider := &toolHookProvider{} al, agent, cleanup := newHookTestLoop(t, provider) @@ -341,6 +712,75 @@ func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { } } +type echoTextRewrittenTool struct{} + +func (t *echoTextRewrittenTool) Name() string { + return "echo_text_rewritten" +} + +func (t *echoTextRewrittenTool) Description() string { + return "echo a rewritten text argument" +} + +func (t *echoTextRewrittenTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + }, + }, + } +} + +func (t *echoTextRewrittenTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + text, _ := args["text"].(string) + return tools.SilentResult("rewritten:" + text) +} + +func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.cfg.Agents.Defaults.ToolFeedback.Enabled = true + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + msgBus, ok := al.bus.(*bus.MessageBus) + if !ok { + t.Fatalf("expected concrete MessageBus, got %T", al.bus) + } + + select { + case outbound := <-msgBus.OutboundChan(): + if !strings.Contains(outbound.Content, "`echo_text_rewritten`") { + t.Fatalf("tool feedback content = %q, want rewritten tool name", outbound.Content) + } + if strings.Contains(outbound.Content, "`echo_text`") { + t.Fatalf("tool feedback content = %q, want no original tool name", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback") + } +} + type denyApprovalHook struct{} func (h *denyApprovalHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { @@ -620,9 +1060,10 @@ func TestAgentLoop_HookRespond_MediaError(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - al.channelManager = newStartedTestChannelManager(t, al.bus, al.mediaStore, "discord", &errorMediaChannel{ - sendErr: errors.New("channel unavailable"), - }) + al.channelManager = newStartedTestChannelManager(t, + al.bus.(*bus.MessageBus), al.mediaStore, "discord", &errorMediaChannel{ + sendErr: errors.New("channel unavailable"), + }) sub := al.SubscribeEvents(16) defer al.UnsubscribeEvents(sub.ID) @@ -714,6 +1155,77 @@ func TestAgentLoop_HookRespond_BusFallback(t *testing.T) { } } +func TestAgentLoop_HookRespond_ResponseHandledMediaPreservesOutboundContext(t *testing.T) { + provider := &multiToolProvider{ + toolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "media_tool", Arguments: map[string]any{}}, + }, + finalContent: "done", + } + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &respondWithMediaHook{ + respondTools: map[string]bool{"media_tool": true}, + media: []string{"media://test/image.png"}, + responseHandled: true, + forLLM: "media sent successfully", + } + if err := al.MountHook(NamedHook("media-hook", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} + al.channelManager = newStartedTestChannelManager(t, + al.bus.(*bus.MessageBus), al.mediaStore, "telegram", telegramChannel) + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "session-topic-media", + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: agent.ID, + Channel: "telegram", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "forum:-100123/42", + }, + }, + InboundContext: &bus.InboundContext{ + Channel: "telegram", + ChatID: "-100123", + TopicID: "42", + ChatType: "group", + SenderID: "user1", + }, + UserMessage: "send media", + }, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + if len(telegramChannel.sentMedia) != 1 { + t.Fatalf("expected exactly 1 sent media message, got %d", len(telegramChannel.sentMedia)) + } + sent := telegramChannel.sentMedia[0] + if sent.Context.Channel != "telegram" || sent.Context.ChatID != "-100123" || sent.Context.TopicID != "42" { + t.Fatalf("unexpected media context: %+v", sent.Context) + } + if sent.AgentID != agent.ID { + t.Fatalf("sent media agent_id = %q, want %q", sent.AgentID, agent.ID) + } + if sent.SessionKey != "session-topic-media" { + t.Fatalf("sent media session_key = %q, want session-topic-media", sent.SessionKey) + } + if sent.Scope == nil || sent.Scope.Values["chat"] != "forum:-100123/42" { + t.Fatalf("unexpected sent media scope: %+v", sent.Scope) + } +} + type multiToolProvider struct { mu sync.Mutex callCount int @@ -791,7 +1303,11 @@ func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) { resultCh <- result{resp: resp, err: err} }() - time.Sleep(50 * time.Millisecond) + select { + case <-tool1ExecCh: + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for tool execution to start") + } if err := al.InterruptGraceful("stop now"); err != nil { t.Fatalf("InterruptGraceful failed: %v", err) @@ -915,6 +1431,56 @@ func TestAgentLoop_HookRespond_SteeringSkipsRemaining(t *testing.T) { } } +func TestCloneStringAnyMap_EmptyMapReturnsNonNil(t *testing.T) { + tests := []struct { + name string + input map[string]any + wantNil bool + wantLen int + }{ + { + name: "nil input returns empty map", + input: nil, + wantNil: false, + wantLen: 0, + }, + { + name: "empty map returns empty map", + input: map[string]any{}, + wantNil: false, + wantLen: 0, + }, + { + name: "populated map is cloned", + input: map[string]any{"key": "value"}, + wantNil: false, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := cloneStringAnyMap(tt.input) + if result == nil { + t.Fatal("cloneStringAnyMap returned nil — MCP tool calls " + + "with no arguments would send null instead of {}") + } + if len(result) != tt.wantLen { + t.Fatalf("expected len %d, got %d", tt.wantLen, len(result)) + } + }) + } + + t.Run("clone does not share underlying map", func(t *testing.T) { + src := map[string]any{"a": 1} + cloned := cloneStringAnyMap(src) + cloned["b"] = 2 + if _, ok := src["b"]; ok { + t.Fatal("modifying clone should not affect source") + } + }) +} + func filterEvents(events []Event, kind EventKind) []Event { var result []Event for _, evt := range events { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 5bcb83087..d0b25a0a8 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -270,8 +270,8 @@ func populateCandidateProvidersFromNames( map[string]any{"name": name, "error": err.Error()}) continue } - protocol, modelID := providers.ExtractProtocol(strings.TrimSpace(mc.Model)) - key := providers.ModelKey(providers.NormalizeProvider(protocol), modelID) + protocol, modelID := providers.ExtractProtocol(mc) + key := providers.ModelKey(protocol, modelID) if _, exists := out[key]; exists { continue } diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 8c71296ed..42bb53d86 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -104,6 +104,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { name string aliasName string modelName string + provider string apiBase string wantProvider string wantModel string @@ -124,6 +125,15 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { wantProvider: "openai", wantModel: "glm-5", }, + { + name: "explicit provider overrides model prefix", + aliasName: "nvidia-gpt", + modelName: "z-ai/glm-5.1", + provider: "nvidia", + apiBase: "https://integrate.api.nvidia.com/v1", + wantProvider: "nvidia", + wantModel: "z-ai/glm-5.1", + }, } for _, tt := range tests { @@ -145,6 +155,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { { ModelName: tt.aliasName, Model: tt.modelName, + Provider: tt.provider, APIBase: tt.apiBase, }, }, @@ -218,6 +229,43 @@ func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel } } +func TestNewAgentInstance_PreservesConfigIdentityForExplicitProviderModelRef(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "nvidia/z-ai/glm-5.1", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "nvidia-glm", + Provider: "nvidia", + Model: "z-ai/glm-5.1", + RPM: 7, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + if len(agent.Candidates) != 1 { + t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) + } + + candidate := agent.Candidates[0] + if candidate.Provider != "nvidia" || candidate.Model != "z-ai/glm-5.1" { + t.Fatalf("candidate = %s/%s, want nvidia/z-ai/glm-5.1", candidate.Provider, candidate.Model) + } + if candidate.IdentityKey != "model_name:nvidia-glm" { + t.Fatalf("identity key = %q, want %q", candidate.IdentityKey, "model_name:nvidia-glm") + } + if candidate.RPM != 7 { + t.Fatalf("RPM = %d, want 7", candidate.RPM) + } +} + func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { workspace := t.TempDir() mediaDir := media.TempDir() diff --git a/pkg/agent/interfaces/interfaces.go b/pkg/agent/interfaces/interfaces.go new file mode 100644 index 000000000..bdf483e20 --- /dev/null +++ b/pkg/agent/interfaces/interfaces.go @@ -0,0 +1,47 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package interfaces + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// MessageBus publishes inbound and outbound messages. +// It is the primary communication channel for the agent loop. +type MessageBus interface { + // PublishInbound sends an inbound message to be processed. + PublishInbound(ctx context.Context, msg bus.InboundMessage) error + + // PublishOutbound sends an outbound message to the appropriate channel. + PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error + + // PublishOutboundMedia sends an outbound media message. + PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // InboundChan returns the channel for receiving inbound messages. + InboundChan() <-chan bus.InboundMessage +} + +// ChannelManager manages channel lifecycle and provides channel access. +type ChannelManager interface { + // GetChannel returns the channel with the given name. + GetChannel(name string) (channels.Channel, bool) + + // GetEnabledChannels returns the list of enabled channel names. + GetEnabledChannels() []string + + // InvokeTypingStop signals that typing has stopped. + InvokeTypingStop(channel, chatID string) + + // SendMessage sends a text message to the specified channel and chat. + SendMessage(ctx context.Context, msg bus.OutboundMessage) error + + // SendMedia sends a media message to the specified channel and chat. + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // SendPlaceholder sends a placeholder message (e.g., for audio transcription). + SendPlaceholder(ctx context.Context, channel, chatID string) bool +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go deleted file mode 100644 index 5c75b5ef8..000000000 --- a/pkg/agent/loop.go +++ /dev/null @@ -1,4011 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package agent - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "path/filepath" - "regexp" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/sipeed/picoclaw/pkg/audio/asr" - "github.com/sipeed/picoclaw/pkg/audio/tts" - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" - "github.com/sipeed/picoclaw/pkg/commands" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/constants" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/routing" - "github.com/sipeed/picoclaw/pkg/session" - "github.com/sipeed/picoclaw/pkg/skills" - "github.com/sipeed/picoclaw/pkg/state" - "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/utils" -) - -type AgentLoop struct { - // Core dependencies - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - - // Event system (from Incoming) - eventBus *EventBus - hooks *HookManager - - // Runtime state - running atomic.Bool - contextManager ContextManager - fallback *providers.FallbackChain - channelManager *channels.Manager - mediaStore media.MediaStore - transcriber asr.Transcriber - cmdRegistry *commands.Registry - mcp mcpRuntime - hookRuntime hookRuntime - steering *steeringQueue - pendingSkills sync.Map - mu sync.RWMutex - - // Concurrent turn management (from HEAD) - activeTurnStates sync.Map // key: sessionKey (string), value: *turnState - subTurnCounter atomic.Int64 // Counter for generating unique SubTurn IDs - - // Turn tracking (from Incoming) - turnSeq atomic.Uint64 - activeRequests sync.WaitGroup - - reloadFunc func() error -} - -// processOptions configures how a message is processed -type processOptions struct { - Dispatch DispatchRequest // Normalized routed request boundary for this turn - SessionKey string // Session identifier for history/context - SessionAliases []string // Compatibility aliases for the session key - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - MessageID string // Current inbound platform message ID - ReplyToMessageID string // Current inbound reply target message ID - SenderID string // Current sender ID for dynamic context - SenderDisplayName string // Current sender display name for dynamic context - UserMessage string // User message content (may include prefix) - ForcedSkills []string // Skills explicitly requested for this message - SystemPromptOverride string // Override the default system prompt (Used by SubTurns) - Media []string // media:// refs from inbound message - InitialSteeringMessages []providers.Message // Steering messages from refactor/agent - DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - AllowInterimPicoPublish bool // Whether pico tool-call interim text can be published when SendResponse is false - SuppressToolFeedback bool // Whether to suppress inline tool feedback messages - NoHistory bool // If true, don't load session history (for heartbeat) - SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) - InboundContext *bus.InboundContext // Normalized inbound facts for events/hooks - RouteResult *routing.ResolvedRoute // Route decision snapshot for events/hooks - SessionScope *session.SessionScope // Session scope snapshot for events/hooks -} - -type continuationTarget struct { - SessionKey string - Channel string - ChatID string -} - -const ( - defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." - toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." - handledToolResponseSummary = "Requested output delivered via tool attachment." - sessionKeyAgentPrefix = "agent:" - metadataKeyMessageKind = "message_kind" - messageKindThought = "thought" - metadataKeyAccountID = "account_id" - metadataKeyGuildID = "guild_id" - metadataKeyTeamID = "team_id" - metadataKeyReplyToMessage = "reply_to_message_id" - metadataKeyParentPeerKind = "parent_peer_kind" - metadataKeyParentPeerID = "parent_peer_id" -) - -func NewAgentLoop( - cfg *config.Config, - msgBus *bus.MessageBus, - provider providers.LLMProvider, -) *AgentLoop { - registry := NewAgentRegistry(cfg, provider) - - // Set up shared fallback chain with rate limiting. - cooldown := providers.NewCooldownTracker() - rl := providers.NewRateLimiterRegistry() - // Register rate limiters for all agents' candidates so that RPM limits - // configured in ModelConfig are enforced before each LLM call. - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - rl.RegisterCandidates(agent.Candidates) - rl.RegisterCandidates(agent.LightCandidates) - } - } - fallbackChain := providers.NewFallbackChain(cooldown, rl) - - // Create state manager using default agent's workspace for channel recording - defaultAgent := registry.GetDefaultAgent() - var stateManager *state.Manager - if defaultAgent != nil { - stateManager = state.NewManager(defaultAgent.Workspace) - } - - eventBus := NewEventBus() - 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)), - } - al.hooks = NewHookManager(eventBus) - configureHookManagerFromConfig(al.hooks, cfg) - al.contextManager = al.resolveContextManager() - - // Register shared tools to all agents (now that al is created) - registerSharedTools(al, cfg, msgBus, registry, provider) - - return al -} - -// registerSharedTools registers tools that are shared across all agents (web, message, spawn). -func registerSharedTools( - al *AgentLoop, - cfg *config.Config, - msgBus *bus.MessageBus, - registry *AgentRegistry, - provider providers.LLMProvider, -) { - allowReadPaths := buildAllowReadPatterns(cfg) - var ttsProvider tts.TTSProvider - if cfg.Tools.IsToolEnabled("send_tts") { - ttsProvider = tts.DetectTTS(cfg) - if ttsProvider == nil { - logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil) - } - } - - for _, agentID := range registry.ListAgentIDs() { - agent, ok := registry.GetAgent(agentID) - if !ok { - continue - } - - if cfg.Tools.IsToolEnabled("web") { - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - Provider: cfg.Tools.Web.Provider, - BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults, - SogouEnabled: cfg.Tools.Web.Sogou.Enabled, - DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), - PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, - SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, - SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, - SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, - GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), - GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, - GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, - GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, - GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, - BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), - BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, - BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, - BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, - Proxy: cfg.Tools.Web.Proxy, - }) - if err != nil { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) - } else if searchTool != nil { - agent.Tools.Register(searchTool) - } - } - if cfg.Tools.IsToolEnabled("web_fetch") { - fetchTool, err := tools.NewWebFetchToolWithProxy( - 50000, - cfg.Tools.Web.Proxy, - cfg.Tools.Web.Format, - cfg.Tools.Web.FetchLimitBytes, - cfg.Tools.Web.PrivateHostWhitelist) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else { - agent.Tools.Register(fetchTool) - } - } - - // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms - if cfg.Tools.IsToolEnabled("i2c") { - agent.Tools.Register(tools.NewI2CTool()) - } - if cfg.Tools.IsToolEnabled("spi") { - agent.Tools.Register(tools.NewSPITool()) - } - - // Message tool - if cfg.Tools.IsToolEnabled("message") { - messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func( - ctx context.Context, - channel, chatID, content, replyToMessageID string, - ) error { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - outboundCtx := bus.NewOutboundContext(channel, chatID, replyToMessageID) - outboundAgentID, outboundSessionKey, outboundScope := outboundTurnMetadata( - tools.ToolAgentID(ctx), - tools.ToolSessionKey(ctx), - tools.ToolSessionScope(ctx), - ) - return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Context: outboundCtx, - AgentID: outboundAgentID, - SessionKey: outboundSessionKey, - Scope: outboundScope, - Content: content, - ReplyToMessageID: replyToMessageID, - }) - }) - agent.Tools.Register(messageTool) - } - if cfg.Tools.IsToolEnabled("reaction") { - reactionTool := tools.NewReactionTool() - reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { - if al.channelManager == nil { - return fmt.Errorf("channel manager not configured") - } - ch, ok := al.channelManager.GetChannel(channel) - if !ok { - return fmt.Errorf("channel %s not found", channel) - } - rc, ok := ch.(channels.ReactionCapable) - if !ok { - return fmt.Errorf("channel %s does not support reactions", channel) - } - _, err := rc.ReactToMessage(ctx, chatID, messageID) - return err - }) - agent.Tools.Register(reactionTool) - } - - // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) - if cfg.Tools.IsToolEnabled("send_file") { - sendFileTool := tools.NewSendFileTool( - agent.Workspace, - cfg.Agents.Defaults.RestrictToWorkspace, - cfg.Agents.Defaults.GetMaxMediaSize(), - nil, - allowReadPaths, - ) - agent.Tools.Register(sendFileTool) - } - - if ttsProvider != nil { - agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) - } - - if cfg.Tools.IsToolEnabled("load_image") { - loadImageTool := tools.NewLoadImageTool( - agent.Workspace, - cfg.Agents.Defaults.RestrictToWorkspace, - cfg.Agents.Defaults.GetMaxMediaSize(), - nil, - allowReadPaths, - ) - agent.Tools.Register(loadImageTool) - } - - // Skill discovery and installation tools - skills_enabled := cfg.Tools.IsToolEnabled("skills") - find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") - install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") - if skills_enabled && (find_skills_enable || install_skills_enable) { - registryMgr := skills.NewRegistryManagerFromToolsConfig(cfg.Tools.Skills) - - if find_skills_enable { - searchCache := skills.NewSearchCache( - cfg.Tools.Skills.SearchCache.MaxSize, - time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, - ) - agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) - } - - if install_skills_enable { - agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) - } - } - - // Spawn and spawn_status tools share a SubagentManager. - // Construct it when either tool is enabled (both require subagent). - spawnEnabled := cfg.Tools.IsToolEnabled("spawn") - spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status") - if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) - subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) - - // Inject a media resolver so the legacy RunToolLoop fallback path can - // resolve media:// refs in the same way the main AgentLoop does. - // This keeps subagent vision support working even when the optimized - // sub-turn spawner path is unavailable. - subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { - return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize()) - }) - - // Set the spawner that links into AgentLoop's turnState - subagentManager.SetSpawner(func( - ctx context.Context, - task, label, targetAgentID string, - tls *tools.ToolRegistry, - maxTokens int, - temperature float64, - hasMaxTokens, hasTemperature bool, - ) (*tools.ToolResult, error) { - // 1. Recover parent Turn State from Context - parentTS := turnStateFromContext(ctx) - if parentTS == nil { - // Fallback: If no turnState exists in context, create an isolated ad-hoc root turn state - // so that the tool can still function outside of an agent loop (e.g. tests, raw invocations). - parentTS = &turnState{ - ctx: ctx, - turnID: "adhoc-root", - depth: 0, - session: nil, // Ephemeral session not needed for adhoc spawn - pendingResults: make(chan *tools.ToolResult, 16), - concurrencySem: make(chan struct{}, 5), - } - } - - // 2. Build Tools slice from registry - var tlSlice []tools.Tool - for _, name := range tls.List() { - if t, ok := tls.Get(name); ok { - tlSlice = append(tlSlice, t) - } - } - - // 3. System Prompt - systemPrompt := "You are a subagent. Complete the given task independently and report the result.\n" + - "You have access to tools - use them as needed to complete your task.\n" + - "After completing the task, provide a clear summary of what was done.\n\n" + - "Task: " + task - - // 4. Resolve Model - modelToUse := agent.Model - if targetAgentID != "" { - if targetAgent, ok := al.GetRegistry().GetAgent(targetAgentID); ok { - modelToUse = targetAgent.Model - } - } - - // 5. Build SubTurnConfig - cfg := SubTurnConfig{ - Model: modelToUse, - Tools: tlSlice, - SystemPrompt: systemPrompt, - } - if hasMaxTokens { - cfg.MaxTokens = maxTokens - } - - // 6. Spawn SubTurn - return spawnSubTurn(ctx, al, parentTS, cfg) - }) - - // Clone the parent's tool registry so subagents can use all - // tools registered so far (file, web, etc.) but NOT spawn/ - // spawn_status which are added below — preventing recursive - // subagent spawning. - subagentManager.SetTools(agent.Tools.Clone()) - if spawnEnabled { - spawnTool := tools.NewSpawnTool(subagentManager) - spawnTool.SetSpawner(NewSubTurnSpawner(al)) - currentAgentID := agentID - spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { - return registry.CanSpawnSubagent(currentAgentID, targetAgentID) - }) - - agent.Tools.Register(spawnTool) - - // Also register the synchronous subagent tool - subagentTool := tools.NewSubagentTool(subagentManager) - subagentTool.SetSpawner(NewSubTurnSpawner(al)) - agent.Tools.Register(subagentTool) - } - if spawnStatusEnabled { - agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager)) - } - } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { - logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) - } - } -} - -func (al *AgentLoop) Run(ctx context.Context) error { - al.running.Store(true) - - if err := al.ensureHooksInitialized(ctx); err != nil { - return err - } - if err := al.ensureMCPInitialized(ctx); err != nil { - return err - } - - idleTicker := time.NewTicker(100 * time.Millisecond) - defer idleTicker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case <-idleTicker.C: - if !al.running.Load() { - return nil - } - case msg, ok := <-al.bus.InboundChan(): - if !ok { - return nil - } - - // Start a goroutine that drains the bus while processMessage is - // running. Only messages that resolve to the active turn scope are - // redirected into steering; other inbound messages are requeued. - drainCancel := func() {} - if activeScope, activeAgentID, ok := al.resolveSteeringTarget(msg); ok { - drainCtx, cancel := context.WithCancel(ctx) - drainCancel = cancel - go al.drainBusToSteering(drainCtx, activeScope, activeAgentID) - } - - // Process message - func() { - defer func() { - if al.channelManager != nil { - al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) - } - }() - // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. - // Currently disabled because files are deleted before the LLM can access their content. - // defer func() { - // if al.mediaStore != nil && msg.MediaScope != "" { - // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { - // logger.WarnCF("agent", "Failed to release media", map[string]any{ - // "scope": msg.MediaScope, - // "error": releaseErr.Error(), - // }) - // } - // } - // }() - - drainCanceled := false - cancelDrain := func() { - if drainCanceled { - return - } - drainCancel() - drainCanceled = true - } - defer cancelDrain() - - response, err := al.processMessage(ctx, msg) - if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) - } - finalResponse := response - - target, targetErr := al.buildContinuationTarget(msg) - if targetErr != nil { - logger.WarnCF("agent", "Failed to build steering continuation target", - map[string]any{ - "channel": msg.Channel, - "error": targetErr.Error(), - }) - return - } - if target == nil { - cancelDrain() - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse) - } - return - } - - for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - logger.InfoCF("agent", "Continuing queued steering after turn end", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "session_key": target.SessionKey, - "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), - }) - - 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(), - }) - return - } - if continued == "" { - return - } - - finalResponse = continued - } - - cancelDrain() - - for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - logger.InfoCF("agent", "Draining steering queued during turn shutdown", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "session_key": target.SessionKey, - "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), - }) - - continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) - if continueErr != nil { - logger.WarnCF("agent", "Failed to continue queued steering after shutdown drain", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "error": continueErr.Error(), - }) - return - } - if continued == "" { - break - } - - finalResponse = continued - } - - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) - } - }() - } - } -} - -// drainBusToSteering consumes inbound messages and redirects messages from the -// active scope into the steering queue. Messages from other scopes are requeued -// so they can be processed normally after the active turn. It drains all -// immediately available messages, blocking for the first one until ctx is done. -func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, activeAgentID string) { - blocking := true - var requeue []bus.InboundMessage - defer func() { - for _, msg := range requeue { - if err := al.requeueInboundMessage(msg); err != nil { - logger.WarnCF("agent", "Failed to flush requeued inbound message", map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - "sender_id": msg.SenderID, - }) - } - } - }() - - for { - var msg bus.InboundMessage - - if blocking { - // Block waiting for the first available message or ctx cancellation. - select { - case <-ctx.Done(): - return - case m, ok := <-al.bus.InboundChan(): - if !ok { - return - } - msg = m - } - } else { - // Non-blocking: drain any remaining queued messages, return when empty. - select { - case m, ok := <-al.bus.InboundChan(): - if !ok { - return - } - msg = m - default: - return - } - } - blocking = false - - msgScope, _, scopeOK := al.resolveSteeringTarget(msg) - if !scopeOK || msgScope != activeScope { - requeue = append(requeue, msg) - continue - } - - // Transcribe audio if needed before steering, so the agent sees text. - msg, _ = al.transcribeAudioInMessage(ctx, msg) - - logger.InfoCF("agent", "Redirecting inbound message to steering queue", - map[string]any{ - "channel": msg.Channel, - "sender_id": msg.SenderID, - "content_len": len(msg.Content), - "scope": activeScope, - }) - - if err := al.enqueueSteeringMessage(activeScope, activeAgentID, providers.Message{ - Role: "user", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), - }); err != nil { - logger.WarnCF("agent", "Failed to steer message, will be lost", - map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - }) - } - } -} - -func (al *AgentLoop) Stop() { - al.running.Store(false) -} - -func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { - if response == "" { - return - } - - alreadySentToSameChat := false - defaultAgent := al.GetRegistry().GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySentToSameChat = mt.HasSentTo(channel, chatID) - } - } - } - - if alreadySentToSameChat { - logger.DebugCF( - "agent", - "Skipped outbound (message tool already sent to same chat)", - map[string]any{"channel": channel, "chat_id": chatID}, - ) - return - } - - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Context: bus.NewOutboundContext(channel, chatID, ""), - Content: response, - }) - logger.InfoCF("agent", "Published outbound response", - map[string]any{ - "channel": channel, - "chat_id": chatID, - "content_len": len(response), - }) -} - -func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) { - if msg.Channel == "system" { - return nil, nil - } - - route, _, err := al.resolveMessageRoute(msg) - if err != nil { - return nil, err - } - allocation := al.allocateRouteSession(route, msg) - - return &continuationTarget{ - SessionKey: resolveScopeKey(allocation.SessionKey, msg.SessionKey), - Channel: msg.Channel, - ChatID: msg.ChatID, - }, nil -} - -// Close releases resources held by agent session stores. Call after Stop. -func (al *AgentLoop) Close() { - mcpManager := al.mcp.takeManager() - - if mcpManager != nil { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - } - - al.GetRegistry().Close() - if al.hooks != nil { - al.hooks.Close() - } - if al.eventBus != nil { - al.eventBus.Close() - } -} - -func outboundContextFromInbound( - inbound *bus.InboundContext, - channel, chatID, replyToMessageID string, -) bus.InboundContext { - if inbound == nil { - return bus.NewOutboundContext(channel, chatID, replyToMessageID) - } - - outboundCtx := *cloneInboundContext(inbound) - if outboundCtx.Channel == "" { - outboundCtx.Channel = channel - } - if outboundCtx.ChatID == "" { - outboundCtx.ChatID = chatID - } - if outboundCtx.ReplyToMessageID == "" { - outboundCtx.ReplyToMessageID = replyToMessageID - } - return outboundCtx -} - -func outboundScopeFromSessionScope(scope *session.SessionScope) *bus.OutboundScope { - if scope == nil { - return nil - } - outboundScope := &bus.OutboundScope{ - Version: scope.Version, - AgentID: scope.AgentID, - Channel: scope.Channel, - Account: scope.Account, - } - if len(scope.Dimensions) > 0 { - outboundScope.Dimensions = append([]string(nil), scope.Dimensions...) - } - if len(scope.Values) > 0 { - outboundScope.Values = make(map[string]string, len(scope.Values)) - for key, value := range scope.Values { - outboundScope.Values[key] = value - } - } - return outboundScope -} - -func outboundTurnMetadata( - agentID, sessionKey string, - scope *session.SessionScope, -) (string, string, *bus.OutboundScope) { - return agentID, sessionKey, outboundScopeFromSessionScope(scope) -} - -func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { - agentID, sessionKey, scope := outboundTurnMetadata(ts.agent.ID, ts.sessionKey, ts.opts.Dispatch.SessionScope) - return bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Context: outboundContextFromInbound( - ts.opts.Dispatch.InboundContext, - ts.channel, - ts.chatID, - ts.opts.Dispatch.ReplyToMessageID(), - ), - AgentID: agentID, - SessionKey: sessionKey, - Scope: scope, - Content: content, - } -} - -// MountHook registers an in-process hook on the agent loop. -func (al *AgentLoop) MountHook(reg HookRegistration) error { - if al == nil || al.hooks == nil { - return fmt.Errorf("hook manager is not initialized") - } - return al.hooks.Mount(reg) -} - -// UnmountHook removes a previously registered in-process hook. -func (al *AgentLoop) UnmountHook(name string) { - if al == nil || al.hooks == nil { - return - } - 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} - } - return al.eventBus.Subscribe(buffer) -} - -// UnsubscribeEvents removes a previously registered event subscriber. -func (al *AgentLoop) UnsubscribeEvents(id uint64) { - if al == nil || al.eventBus == nil { - return - } - al.eventBus.Unsubscribe(id) -} - -// 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 - } - return al.eventBus.Dropped(kind) -} - -type turnEventScope struct { - agentID string - sessionKey string - turnID string - context *TurnContext -} - -func (al *AgentLoop) newTurnEventScope(agentID, sessionKey string, turnCtx *TurnContext) turnEventScope { - seq := al.turnSeq.Add(1) - return turnEventScope{ - agentID: agentID, - sessionKey: sessionKey, - turnID: fmt.Sprintf("%s-turn-%d", agentID, seq), - context: cloneTurnContext(turnCtx), - } -} - -func (ts turnEventScope) meta(iteration int, source, tracePath string) EventMeta { - return EventMeta{ - AgentID: ts.agentID, - TurnID: ts.turnID, - SessionKey: ts.sessionKey, - Iteration: iteration, - Source: source, - TracePath: tracePath, - turnContext: cloneTurnContext(ts.context), - } -} - -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, - } - - if al == nil || al.eventBus == nil { - return - } - - al.logEvent(evt) - - al.eventBus.Emit(evt) -} - -func cloneEventArguments(args map[string]any) map[string]any { - if len(args) == 0 { - return nil - } - - cloned := make(map[string]any, len(args)) - for k, v := range args { - cloned[k] = v - } - return cloned -} - -func (al *AgentLoop) hookAbortError(ts *turnState, stage string, decision HookDecision) error { - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - - err := fmt.Errorf("hook aborted turn during %s: %s", stage, reason) - al.emitEvent( - EventKindError, - ts.eventMeta("hooks", "turn.error"), - ErrorPayload{ - Stage: "hook." + stage, - Message: err.Error(), - }, - ) - return err -} - -func hookDeniedToolContent(prefix, reason string) string { - if reason == "" { - return prefix - } - return prefix + ": " + reason -} - -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) -} - -func appendEventContextFields(fields map[string]any, turnCtx *TurnContext) { - if turnCtx == nil { - return - } - - if inbound := turnCtx.Inbound; inbound != nil { - if inbound.Channel != "" { - fields["inbound_channel"] = inbound.Channel - } - if inbound.Account != "" { - fields["inbound_account"] = inbound.Account - } - if inbound.ChatID != "" { - fields["inbound_chat_id"] = inbound.ChatID - } - if inbound.ChatType != "" { - fields["inbound_chat_type"] = inbound.ChatType - } - if inbound.TopicID != "" { - fields["inbound_topic_id"] = inbound.TopicID - } - if inbound.SpaceType != "" { - fields["inbound_space_type"] = inbound.SpaceType - } - if inbound.SpaceID != "" { - fields["inbound_space_id"] = inbound.SpaceID - } - if inbound.SenderID != "" { - fields["inbound_sender_id"] = inbound.SenderID - } - if inbound.Mentioned { - fields["inbound_mentioned"] = true - } - } - - if route := turnCtx.Route; route != nil { - if route.AgentID != "" { - fields["route_agent_id"] = route.AgentID - } - if route.Channel != "" { - fields["route_channel"] = route.Channel - } - if route.AccountID != "" { - fields["route_account_id"] = route.AccountID - } - if route.MatchedBy != "" { - fields["route_matched_by"] = route.MatchedBy - } - if len(route.SessionPolicy.Dimensions) > 0 { - fields["route_dimensions"] = strings.Join(route.SessionPolicy.Dimensions, ",") - } - if count := len(route.SessionPolicy.IdentityLinks); count > 0 { - fields["route_identity_link_count"] = count - } - } - - if scope := turnCtx.Scope; scope != nil { - if scope.Version > 0 { - fields["scope_version"] = scope.Version - } - if scope.AgentID != "" { - fields["scope_agent_id"] = scope.AgentID - } - if scope.Channel != "" { - fields["scope_channel"] = scope.Channel - } - if scope.Account != "" { - fields["scope_account"] = scope.Account - } - if len(scope.Dimensions) > 0 { - fields["scope_dimensions"] = strings.Join(scope.Dimensions, ",") - } - for dim, value := range scope.Values { - if dim == "" || value == "" { - continue - } - fields["scope_"+dim] = value - } - } -} - -func (al *AgentLoop) RegisterTool(tool tools.Tool) { - registry := al.GetRegistry() - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - agent.Tools.Register(tool) - } - } -} - -func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { - al.channelManager = cm -} - -// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization. -// It uses a context to allow timeout control from the caller. -// Returns an error if the reload fails or context is canceled. -func (al *AgentLoop) ReloadProviderAndConfig( - ctx context.Context, - provider providers.LLMProvider, - cfg *config.Config, -) error { - // Validate inputs - if provider == nil { - return fmt.Errorf("provider cannot be nil") - } - if cfg == nil { - return fmt.Errorf("config cannot be nil") - } - - // Create new registry with updated config and provider - // Wrap in defer/recover to handle any panics gracefully - var registry *AgentRegistry - var panicErr error - done := make(chan struct{}, 1) - - go func() { - defer func() { - if r := recover(); r != nil { - logger.RecoverPanicNoExit(r) - panicErr = fmt.Errorf("panic during registry creation: %v", r) - logger.ErrorCF("agent", "Panic during registry creation", - map[string]any{"panic": r}) - } - close(done) - }() - - registry = NewAgentRegistry(cfg, provider) - }() - - // Wait for completion or context cancellation - select { - case <-done: - if registry == nil { - if panicErr != nil { - return fmt.Errorf("registry creation failed: %w", panicErr) - } - return fmt.Errorf("registry creation failed (nil result)") - } - case <-ctx.Done(): - return fmt.Errorf("context canceled during registry creation: %w", ctx.Err()) - } - - // Check context again before proceeding - if err := ctx.Err(); err != nil { - return fmt.Errorf("context canceled after registry creation: %w", err) - } - - // Ensure shared tools are re-registered on the new registry - registerSharedTools(al, cfg, al.bus, registry, provider) - - // Atomically swap the config and registry under write lock - // This ensures readers see a consistent pair - al.mu.Lock() - oldRegistry := al.registry - - // Store new values - al.cfg = cfg - al.registry = registry - - // Also update fallback chain with new config; rebuild rate limiter registry. - newRL := providers.NewRateLimiterRegistry() - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - newRL.RegisterCandidates(agent.Candidates) - newRL.RegisterCandidates(agent.LightCandidates) - } - } - al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) - - al.mu.Unlock() - - oldMCPManager := al.mcp.reset() - al.hookRuntime.reset(al) - configureHookManagerFromConfig(al.hooks, cfg) - if err := al.ensureHooksInitialized(ctx); err != nil { - logger.WarnCF("agent", "Configured hooks failed to reinitialize after reload", - map[string]any{"error": err.Error()}) - } - if oldMCPManager != nil { - if err := oldMCPManager.Close(); err != nil { - logger.WarnCF("agent", "Failed to close previous MCP manager during reload", - map[string]any{"error": err.Error()}) - } - } - if err := al.ensureMCPInitialized(ctx); err != nil { - logger.WarnCF("agent", "MCP failed to reinitialize after reload", - map[string]any{"error": err.Error()}) - } - - // Close old provider after releasing the lock - // This prevents blocking readers while closing - if oldProvider, ok := extractProvider(oldRegistry); ok { - if stateful, ok := oldProvider.(providers.StatefulProvider); ok { - // Give in-flight requests a moment to complete - // Use a reasonable timeout that balances cleanup vs resource usage - select { - case <-time.After(100 * time.Millisecond): - stateful.Close() - case <-ctx.Done(): - // Context canceled, close immediately but log warning - logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close", - map[string]any{"error": ctx.Err()}) - stateful.Close() - } - } - } - - logger.InfoCF("agent", "Provider and config reloaded successfully", - map[string]any{ - "model": cfg.Agents.Defaults.GetModelName(), - }) - - return nil -} - -// GetRegistry returns the current registry (thread-safe) -func (al *AgentLoop) GetRegistry() *AgentRegistry { - al.mu.RLock() - defer al.mu.RUnlock() - return al.registry -} - -// GetConfig returns the current config (thread-safe) -func (al *AgentLoop) GetConfig() *config.Config { - al.mu.RLock() - defer al.mu.RUnlock() - return al.cfg -} - -// SetMediaStore injects a MediaStore for media lifecycle management. -func (al *AgentLoop) SetMediaStore(s media.MediaStore) { - al.mediaStore = s - - // Propagate store to all registered tools that can emit media. - registry := al.GetRegistry() - for _, agentID := range registry.ListAgentIDs() { - if agent, ok := registry.GetAgent(agentID); ok { - agent.Tools.SetMediaStore(s) - } - } - registry.ForEachTool("send_tts", func(t tools.Tool) { - if st, ok := t.(*tools.SendTTSTool); ok { - st.SetMediaStore(s) - } - }) -} - -// SetTranscriber injects a voice transcriber for agent-level audio transcription. -func (al *AgentLoop) SetTranscriber(t asr.Transcriber) { - al.transcriber = t -} - -// SetReloadFunc sets the callback function for triggering config reload. -func (al *AgentLoop) SetReloadFunc(fn func() error) { - al.reloadFunc = fn -} - -var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) - -// transcribeAudioInMessage resolves audio media refs, transcribes them, and -// replaces audio annotations in msg.Content with the transcribed text. -// Returns the (possibly modified) message and true if audio was transcribed. -func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { - if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { - return msg, false - } - - // Transcribe each audio media ref in order. - var transcriptions []string - var keptMedia []string - for _, ref := range msg.Media { - path, meta, err := al.mediaStore.ResolveWithMeta(ref) - if err != nil { - logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) - keptMedia = append(keptMedia, ref) - continue - } - if !utils.IsAudioFile(meta.Filename, meta.ContentType) { - keptMedia = append(keptMedia, ref) - continue - } - result, err := al.transcriber.Transcribe(ctx, path) - if err != nil { - logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) - transcriptions = append(transcriptions, "") - keptMedia = append(keptMedia, ref) - continue - } - transcriptions = append(transcriptions, result.Text) - } - - if len(transcriptions) == 0 { - return msg, false - } - - al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) - - // Replace audio annotations sequentially with transcriptions. - idx := 0 - newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { - if idx >= len(transcriptions) { - return match - } - text := transcriptions[idx] - idx++ - if text == "" { - return match - } - return "[voice: " + text + "]" - }) - - // Append any remaining transcriptions not matched by an annotation. - for ; idx < len(transcriptions); idx++ { - if transcriptions[idx] != "" { - newContent += "\n[voice: " + transcriptions[idx] + "]" - } - } - - msg.Content = newContent - msg.Media = keptMedia - return msg, true -} - -// sendTranscriptionFeedback sends feedback to the user with the result of -// audio transcription if the option is enabled. It uses Manager.SendMessage -// which executes synchronously (rate limiting, splitting, retry) so that -// ordering with the subsequent placeholder is guaranteed. -func (al *AgentLoop) sendTranscriptionFeedback( - ctx context.Context, - channel, chatID, messageID string, - validTexts []string, -) { - if !al.cfg.Voice.EchoTranscription { - return - } - if al.channelManager == nil { - return - } - - var nonEmpty []string - for _, t := range validTexts { - if t != "" { - nonEmpty = append(nonEmpty, t) - } - } - - var feedbackMsg string - if len(nonEmpty) > 0 { - feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") - } else { - feedbackMsg = "No voice detected in the audio" - } - - err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ - Context: bus.NewOutboundContext(channel, chatID, messageID), - Content: feedbackMsg, - ReplyToMessageID: messageID, - }) - if err != nil { - logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) - } -} - -// inferMediaType determines the media type ("image", "audio", "video", "file") -// from a filename and MIME content type. -func inferMediaType(filename, contentType string) string { - ct := strings.ToLower(contentType) - fn := strings.ToLower(filename) - - if strings.HasPrefix(ct, "image/") { - return "image" - } - if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { - return "audio" - } - if strings.HasPrefix(ct, "video/") { - return "video" - } - - // Fallback: infer from extension - ext := filepath.Ext(fn) - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": - return "image" - case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": - return "audio" - case ".mp4", ".avi", ".mov", ".webm", ".mkv": - return "video" - } - - return "file" -} - -// RecordLastChannel records the last active channel for this workspace. -// This uses the atomic state save mechanism to prevent data loss on crash. -func (al *AgentLoop) RecordLastChannel(channel string) error { - if al.state == nil { - return nil - } - return al.state.SetLastChannel(channel) -} - -// RecordLastChatID records the last active chat ID for this workspace. -// This uses the atomic state save mechanism to prevent data loss on crash. -func (al *AgentLoop) RecordLastChatID(chatID string) error { - if al.state == nil { - return nil - } - return al.state.SetLastChatID(chatID) -} - -func (al *AgentLoop) ProcessDirect( - ctx context.Context, - content, sessionKey string, -) (string, error) { - return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") -} - -func (al *AgentLoop) ProcessDirectWithChannel( - ctx context.Context, - content, sessionKey, channel, chatID string, -) (string, error) { - if err := al.ensureHooksInitialized(ctx); err != nil { - return "", err - } - if err := al.ensureMCPInitialized(ctx); err != nil { - return "", err - } - - msg := bus.InboundMessage{ - Context: bus.InboundContext{ - Channel: channel, - ChatID: chatID, - ChatType: "direct", - SenderID: "cron", - }, - Content: content, - SessionKey: sessionKey, - } - - return al.processMessage(ctx, msg) -} - -// ProcessHeartbeat processes a heartbeat request without session history. -// Each heartbeat is independent and doesn't accumulate context. -func (al *AgentLoop) ProcessHeartbeat( - ctx context.Context, - content, channel, chatID string, -) (string, error) { - if err := al.ensureHooksInitialized(ctx); err != nil { - return "", err - } - if err := al.ensureMCPInitialized(ctx); err != nil { - return "", err - } - - agent := al.GetRegistry().GetDefaultAgent() - if agent == nil { - return "", fmt.Errorf("no default agent for heartbeat") - } - dispatch := DispatchRequest{ - SessionKey: "heartbeat", - UserMessage: content, - } - if channel != "" || chatID != "" { - dispatch.InboundContext = &bus.InboundContext{ - Channel: channel, - ChatID: chatID, - ChatType: "direct", - SenderID: "heartbeat", - } - } - return al.runAgentLoop(ctx, agent, processOptions{ - Dispatch: dispatch, - DefaultResponse: defaultResponse, - EnableSummary: false, - SendResponse: false, - SuppressToolFeedback: true, - NoHistory: true, // Don't load session history for heartbeat - }) -} - -func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { - msg = bus.NormalizeInboundMessage(msg) - - // Add message preview to log (show full content for error messages) - var logContent string - if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { - logContent = msg.Content // Full content for errors - } else { - logContent = utils.Truncate(msg.Content, 80) - } - logger.InfoCF( - "agent", - fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent), - map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "sender_id": msg.SenderID, - "session_key": msg.SessionKey, - }, - ) - - var hadAudio bool - msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) - - // For audio messages the placeholder was deferred by the channel. - // Now that transcription (and optional feedback) is done, send it. - if hadAudio && al.channelManager != nil { - al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) - } - - // Route system messages to processSystemMessage - if msg.Channel == "system" { - return al.processSystemMessage(ctx, msg) - } - - route, agent, routeErr := al.resolveMessageRoute(msg) - if routeErr != nil { - return "", routeErr - } - - // Reset message-tool state for this round so we don't skip publishing due to a previous round. - if tool, ok := agent.Tools.Get("message"); ok { - if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { - resetter.ResetSentInRound() - } - } - - allocation := al.allocateRouteSession(route, msg) - - // Resolve session key from the route allocation, while preserving explicit - // agent-scoped keys supplied by the caller. - scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) - sessionKey := scopeKey - - logger.InfoCF("agent", "Routed message", - map[string]any{ - "agent_id": agent.ID, - "scope_key": scopeKey, - "session_key": sessionKey, - "matched_by": route.MatchedBy, - "route_agent": route.AgentID, - "route_channel": route.Channel, - "route_main_session": allocation.MainSessionKey, - }) - - opts := processOptions{ - Dispatch: DispatchRequest{ - SessionKey: sessionKey, - SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...), - InboundContext: cloneInboundContext(&msg.Context), - RouteResult: cloneResolvedRoute(&route), - SessionScope: session.CloneScope(&allocation.Scope), - UserMessage: msg.Content, - Media: append([]string(nil), msg.Media...), - }, - SenderID: msg.SenderID, - SenderDisplayName: msg.Sender.DisplayName, - DefaultResponse: defaultResponse, - EnableSummary: true, - SendResponse: false, - AllowInterimPicoPublish: true, - } - - // context-dependent commands check their own Runtime fields and report - // "unavailable" when the required capability is nil. - if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { - return response, nil - } - - if pending := al.takePendingSkills(opts.Dispatch.SessionKey); len(pending) > 0 { - opts.ForcedSkills = append(opts.ForcedSkills, pending...) - logger.InfoCF("agent", "Applying pending skill override", - map[string]any{ - "session_key": opts.Dispatch.SessionKey, - "skills": strings.Join(pending, ","), - }) - } - - return al.runAgentLoop(ctx, agent, opts) -} - -func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { - registry := al.GetRegistry() - inboundCtx := normalizedInboundContext(msg) - route := registry.ResolveRoute(inboundCtx) - - agent, ok := registry.GetAgent(route.AgentID) - if !ok { - agent = registry.GetDefaultAgent() - } - if agent == nil { - return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) - } - - return route, agent, nil -} - -func normalizedInboundContext(msg bus.InboundMessage) bus.InboundContext { - return bus.NormalizeInboundMessage(msg).Context -} - -func resolveScopeKey(routeSessionKey, msgSessionKey string) string { - if isExplicitSessionKey(msgSessionKey) { - return msgSessionKey - } - return routeSessionKey -} - -func isExplicitSessionKey(sessionKey string) bool { - return session.IsExplicitSessionKey(sessionKey) -} - -func buildSessionAliases(canonicalKey string, keys ...string) []string { - if len(keys) == 0 { - return nil - } - aliases := make([]string, 0, len(keys)) - seen := make(map[string]struct{}, len(keys)) - canonicalKey = strings.TrimSpace(canonicalKey) - for _, key := range keys { - key = strings.TrimSpace(key) - if key == "" || key == canonicalKey { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - aliases = append(aliases, key) - } - if len(aliases) == 0 { - return nil - } - return aliases -} - -func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) { - if key == "" || scope == nil { - return - } - metaStore, ok := store.(interface { - EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string) - }) - if !ok { - return - } - metaStore.EnsureSessionMetadata(key, scope, aliases) -} - -func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation { - return session.AllocateRouteSession(session.AllocationInput{ - AgentID: route.AgentID, - Context: normalizedInboundContext(msg), - SessionPolicy: route.SessionPolicy, - }) -} - -func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { - if msg.Channel == "system" { - return "", "", false - } - - route, agent, err := al.resolveMessageRoute(msg) - if err != nil || agent == nil { - return "", "", false - } - allocation := al.allocateRouteSession(route, msg) - - return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true -} - -func (al *AgentLoop) requeueInboundMessage(msg bus.InboundMessage) error { - if al.bus == nil { - return nil - } - pubCtx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - return al.bus.PublishInbound(pubCtx, msg) -} - -func (al *AgentLoop) processSystemMessage( - ctx context.Context, - msg bus.InboundMessage, -) (string, error) { - if msg.Channel != "system" { - return "", fmt.Errorf( - "processSystemMessage called with non-system message channel: %s", - msg.Channel, - ) - } - - logger.InfoCF("agent", "Processing system message", - map[string]any{ - "sender_id": msg.SenderID, - "chat_id": msg.ChatID, - }) - - // Parse origin channel from chat_id (format: "channel:chat_id") - var originChannel, originChatID string - if idx := strings.Index(msg.ChatID, ":"); idx > 0 { - originChannel = msg.ChatID[:idx] - originChatID = msg.ChatID[idx+1:] - } else { - originChannel = "cli" - originChatID = msg.ChatID - } - - // Extract subagent result from message content - // Format: "Task 'label' completed.\n\nResult:\n" - content := msg.Content - if idx := strings.Index(content, "Result:\n"); idx >= 0 { - content = content[idx+8:] // Extract just the result part - } - - // Skip internal channels - only log, don't send to user - if constants.IsInternalChannel(originChannel) { - logger.InfoCF("agent", "Subagent completed (internal channel)", - map[string]any{ - "sender_id": msg.SenderID, - "content_len": len(content), - "channel": originChannel, - }) - return "", nil - } - - // Use default agent for system messages - agent := al.GetRegistry().GetDefaultAgent() - if agent == nil { - return "", fmt.Errorf("no default agent for system message") - } - - // Use the origin session for context - sessionKey := session.BuildMainSessionKey(agent.ID) - dispatch := DispatchRequest{ - SessionKey: sessionKey, - UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), - } - if originChannel != "" || originChatID != "" { - dispatch.InboundContext = &bus.InboundContext{ - Channel: originChannel, - ChatID: originChatID, - ChatType: "direct", - SenderID: msg.SenderID, - } - } - - return al.runAgentLoop(ctx, agent, processOptions{ - Dispatch: dispatch, - DefaultResponse: "Background task completed.", - EnableSummary: false, - SendResponse: true, - }) -} - -// runAgentLoop remains the top-level shell that starts a turn and publishes -// any post-turn work. runTurn owns the full turn lifecycle. -func (al *AgentLoop) runAgentLoop( - ctx context.Context, - agent *AgentInstance, - opts processOptions, -) (string, error) { - opts = normalizeProcessOptions(opts) - - // Record last channel for heartbeat notifications (skip internal channels and cli) - if opts.Dispatch.Channel() != "" && - opts.Dispatch.ChatID() != "" && - !constants.IsInternalChannel(opts.Dispatch.Channel()) { - channelKey := fmt.Sprintf("%s:%s", opts.Dispatch.Channel(), opts.Dispatch.ChatID()) - if err := al.RecordLastChannel(channelKey); err != nil { - logger.WarnCF( - "agent", - "Failed to record last channel", - map[string]any{"error": err.Error()}, - ) - } - } - - ensureSessionMetadata( - agent.Sessions, - opts.Dispatch.SessionKey, - opts.Dispatch.SessionScope, - opts.Dispatch.SessionAliases, - ) - - turnScope := al.newTurnEventScope( - agent.ID, - opts.Dispatch.SessionKey, - newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope), - ) - ts := newTurnState(agent, opts, turnScope) - result, err := al.runTurn(ctx, ts) - if err != nil { - return "", err - } - if result.status == TurnEndStatusAborted { - return "", nil - } - - for _, followUp := range result.followUps { - if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil { - logger.WarnCF("agent", "Failed to publish follow-up after turn", - map[string]any{ - "turn_id": ts.turnID, - "error": pubErr.Error(), - }) - } - } - - if opts.SendResponse && result.finalContent != "" { - agentID, sessionKey, scope := outboundTurnMetadata( - agent.ID, - opts.Dispatch.SessionKey, - opts.Dispatch.SessionScope, - ) - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Context: outboundContextFromInbound( - opts.Dispatch.InboundContext, - opts.Dispatch.Channel(), - opts.Dispatch.ChatID(), - opts.Dispatch.ReplyToMessageID(), - ), - AgentID: agentID, - SessionKey: sessionKey, - Scope: scope, - Content: result.finalContent, - }) - } - - if result.finalContent != "" { - responsePreview := utils.Truncate(result.finalContent, 120) - logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]any{ - "agent_id": agent.ID, - "session_key": opts.Dispatch.SessionKey, - "iterations": ts.currentIteration(), - "final_length": len(result.finalContent), - }) - } - - return result.finalContent, nil -} - -func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { - if al.channelManager == nil { - return "" - } - if ch, ok := al.channelManager.GetChannel(channelName); ok { - return ch.ReasoningChannelID() - } - return "" -} - -func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, chatID string) { - if reasoningContent == "" || chatID == "" { - return - } - - if ctx.Err() != nil { - return - } - - pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) - defer pubCancel() - - if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Context: bus.InboundContext{ - Channel: "pico", - ChatID: chatID, - Raw: map[string]string{ - metadataKeyMessageKind: messageKindThought, - }, - }, - Content: reasoningContent, - }); err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || - errors.Is(err, bus.ErrBusClosed) { - logger.DebugCF("agent", "Pico reasoning publish skipped (timeout/cancel)", map[string]any{ - "channel": "pico", - "error": err.Error(), - }) - } else { - logger.WarnCF("agent", "Failed to publish pico reasoning (best-effort)", map[string]any{ - "channel": "pico", - "error": err.Error(), - }) - } - } -} - -func (al *AgentLoop) handleReasoning( - ctx context.Context, - reasoningContent, channelName, channelID string, -) { - if reasoningContent == "" || channelName == "" || channelID == "" { - return - } - - // Check context cancellation before attempting to publish, - // since PublishOutbound's select may race between send and ctx.Done(). - if ctx.Err() != nil { - return - } - - // Use a short timeout so the goroutine does not block indefinitely when - // the outbound bus is full. Reasoning output is best-effort; dropping it - // is acceptable to avoid goroutine accumulation. - pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) - defer pubCancel() - - if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Context: bus.NewOutboundContext(channelName, channelID, ""), - Content: reasoningContent, - }); err != nil { - // Treat context.DeadlineExceeded / context.Canceled as expected - // (bus full under load, or parent canceled). Check the error - // itself rather than ctx.Err(), because pubCtx may time out - // (5 s) while the parent ctx is still active. - // Also treat ErrBusClosed as expected — it occurs during normal - // shutdown when the bus is closed before all goroutines finish. - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || - errors.Is(err, bus.ErrBusClosed) { - logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ - "channel": channelName, - "error": err.Error(), - }) - } else { - logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ - "channel": channelName, - "error": err.Error(), - }) - } - } -} - -func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) { - turnCtx, turnCancel := context.WithCancel(ctx) - defer turnCancel() - ts.setTurnCancel(turnCancel) - - // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. - turnCtx = withTurnState(turnCtx, ts) - turnCtx = WithAgentLoop(turnCtx, al) - - al.registerActiveTurn(ts) - defer al.clearActiveTurn(ts) - - turnStatus := TurnEndStatusCompleted - defer func() { - al.emitEvent( - EventKindTurnEnd, - ts.eventMeta("runTurn", "turn.end"), - TurnEndPayload{ - Status: turnStatus, - Iterations: ts.currentIteration(), - Duration: time.Since(ts.startedAt), - FinalContentLen: ts.finalContentLen(), - }, - ) - }() - - al.emitEvent( - EventKindTurnStart, - ts.eventMeta("runTurn", "turn.start"), - TurnStartPayload{ - UserMessage: ts.userMessage, - MediaCount: len(ts.media), - }, - ) - - var history []providers.Message - var summary string - if !ts.opts.NoHistory { - // ContextManager assembles budget-aware history and summary. - if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - } - ts.captureRestorePoint(history, summary) - - messages := ts.agent.ContextBuilder.BuildMessages( - history, - summary, - ts.userMessage, - ts.media, - ts.channel, - ts.chatID, - ts.opts.Dispatch.SenderID(), - ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - - cfg := al.GetConfig() - maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - - if !ts.opts.NoHistory { - toolDefs := ts.agent.Tools.ToProviderDefs() - if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { - logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", - map[string]any{"session_key": ts.sessionKey}) - if err := al.contextManager.Compact(turnCtx, &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonProactive, - Budget: ts.agent.ContextWindow, - }); err != nil { - logger.WarnCF("agent", "Proactive compact failed", map[string]any{ - "session_key": ts.sessionKey, - "error": err.Error(), - }) - } - ts.refreshRestorePointFromSession(ts.agent) - // Re-assemble from CM after compact. - if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - messages = ts.agent.ContextBuilder.BuildMessages( - history, summary, ts.userMessage, - ts.media, ts.channel, ts.chatID, - ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - } - } - - // Save user message to session (from Incoming) - if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { - rootMsg := providers.Message{ - Role: "user", - Content: ts.userMessage, - Media: append([]string(nil), ts.media...), - } - if len(rootMsg.Media) > 0 { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) - } else { - ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) - } - ts.recordPersistedMessage(rootMsg) - ts.ingestMessage(turnCtx, al, rootMsg) - } - - activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) - activeProvider := ts.agent.Provider - if usedLight && ts.agent.LightProvider != nil { - activeProvider = ts.agent.LightProvider - } - pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...) - var finalContent string - -turnLoop: - for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool { - graceful, _ := ts.gracefulInterruptRequested() - return graceful - }() { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - iteration := ts.currentIteration() + 1 - ts.setIteration(iteration) - ts.setPhase(TurnPhaseRunning) - - if iteration > 1 { - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - } else if !ts.opts.SkipInitialSteeringPoll { - if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - } - - // Check if parent turn has ended (SubTurn support from HEAD) - if ts.parentTurnState != nil && ts.IsParentEnded() { - if !ts.critical { - logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ - "agent_id": ts.agentID, - "iteration": iteration, - "turn_id": ts.turnID, - }) - break - } - logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ - "agent_id": ts.agentID, - "iteration": iteration, - "turn_id": ts.turnID, - }) - } - - // Poll for pending SubTurn results (from HEAD) - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - pendingMessages = append(pendingMessages, msg) - } - default: - // No results available - } - } - - // Inject pending steering messages - if len(pendingMessages) > 0 { - resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) - totalContentLen := 0 - for i, pm := range pendingMessages { - messages = append(messages, resolvedPending[i]) - totalContentLen += len(pm.Content) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) - ts.recordPersistedMessage(pm) - ts.ingestMessage(turnCtx, al, pm) - } - logger.InfoCF("agent", "Injected steering message into context", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_len": len(pm.Content), - "media_count": len(pm.Media), - }) - } - al.emitEvent( - EventKindSteeringInjected, - ts.eventMeta("runTurn", "turn.steering.injected"), - SteeringInjectedPayload{ - Count: len(pendingMessages), - TotalContentLen: totalContentLen, - }, - ) - pendingMessages = nil - } - - logger.DebugCF("agent", "LLM iteration", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "max": ts.agent.MaxIterations, - }) - - gracefulTerminal, _ := ts.gracefulInterruptRequested() - providerToolDefs := ts.agent.Tools.ToProviderDefs() - - // Native web search support (from HEAD) - _, hasWebSearch := ts.agent.Tools.Get("web_search") - useNativeSearch := al.cfg.Tools.Web.PreferNative && - hasWebSearch && - func() bool { - // Check if provider supports native search - if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { - return ns.SupportsNativeSearch() - } - return false - }() - - if useNativeSearch { - // Filter out client-side web_search tool - filtered := make([]providers.ToolDefinition, 0, len(providerToolDefs)) - for _, td := range providerToolDefs { - if td.Function.Name != "web_search" { - filtered = append(filtered, td) - } - } - providerToolDefs = filtered - } - - // Resolve media:// refs produced by tool results (e.g. load_image). - // Skipped on iteration 1 because inbound user media is already resolved - // before entering the loop; only subsequent iterations can contain new - // tool-generated media refs that need base64 encoding. - if iteration > 1 { - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - } - - callMessages := messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - providerToolDefs = nil - ts.markGracefulTerminalUsed() - } - - llmOpts := map[string]any{ - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "prompt_cache_key": ts.agent.ID, - } - if useNativeSearch { - llmOpts["native_search"] = true - } - if ts.agent.ThinkingLevel != ThinkingOff { - if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) - } else { - logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", - map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) - } - } - - llmModel := activeModel - if al.hooks != nil { - llmReq, decision := al.hooks.BeforeLLM(turnCtx, &LLMHookRequest{ - Meta: ts.eventMeta("runTurn", "turn.llm.request"), - Context: cloneTurnContext(ts.turnCtx), - Model: llmModel, - Messages: callMessages, - Tools: providerToolDefs, - Options: llmOpts, - GracefulTerminal: gracefulTerminal, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmReq != nil { - llmModel = llmReq.Model - callMessages = llmReq.Messages - providerToolDefs = llmReq.Tools - llmOpts = llmReq.Options - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - al.emitEvent( - EventKindLLMRequest, - ts.eventMeta("runTurn", "turn.llm.request"), - LLMRequestPayload{ - Model: llmModel, - MessagesCount: len(callMessages), - ToolsCount: len(providerToolDefs), - MaxTokens: ts.agent.MaxTokens, - Temperature: ts.agent.Temperature, - }, - ) - - logger.DebugCF("agent", "LLM request", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "model": llmModel, - "messages_count": len(callMessages), - "tools_count": len(providerToolDefs), - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "system_prompt_len": len(callMessages[0].Content), - }) - logger.DebugCF("agent", "Full LLM request", - map[string]any{ - "iteration": iteration, - "messages_json": formatMessagesForLog(callMessages), - "tools_json": formatToolsForLog(providerToolDefs), - }) - - callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { - providerCtx, providerCancel := context.WithCancel(turnCtx) - ts.setProviderCancel(providerCancel) - defer func() { - providerCancel() - ts.clearProviderCancel(providerCancel) - }() - - al.activeRequests.Add(1) - defer al.activeRequests.Done() - - if len(activeCandidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute( - providerCtx, - activeCandidates, - func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - candidateProvider := activeProvider - if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { - candidateProvider = cp - } - return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts) - }, - ) - if fbErr != nil { - return nil, fbErr - } - if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { - logger.InfoCF( - "agent", - fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", - fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, - ) - } - return fbResult.Response, nil - } - return activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, llmModel, llmOpts) - } - - var response *providers.LLMResponse - var err error - maxRetries := 2 - callHasMedia := messagesContainMedia(callMessages) - didStripMedia := false - for retry := 0; retry <= maxRetries; retry++ { - response, err = callLLM(callMessages, providerToolDefs) - if err == nil { - break - } - if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - // If the provider/model doesn't support multimodal inputs, retry once with media stripped - // so the session doesn't get "stuck" after a user sends an image. - if callHasMedia && !didStripMedia && isVisionUnsupportedError(err) { - didStripMedia = true - if !ts.opts.NoHistory { - history = ts.agent.Sessions.GetHistory(ts.sessionKey) - ts.agent.Sessions.SetHistory(ts.sessionKey, stripMessageMedia(history)) - - // Keep persistedMessages aligned so abort restore-point trimming remains correct. - ts.mu.Lock() - for i := range ts.persistedMessages { - ts.persistedMessages[i].Media = nil - } - ts.mu.Unlock() - - ts.refreshRestorePointFromSession(ts.agent) - } - - messages = stripMessageMedia(messages) - callMessages = stripMessageMedia(callMessages) - callHasMedia = false - - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: 1, - MaxRetries: 1, - Reason: "vision_unsupported", - Error: err.Error(), - Backoff: 0, - }, - ) - response, err = callLLM(callMessages, providerToolDefs) - if err == nil { - break - } - } - - errMsg := strings.ToLower(err.Error()) - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - strings.Contains(errMsg, "deadline exceeded") || - strings.Contains(errMsg, "client.timeout") || - strings.Contains(errMsg, "timed out") || - strings.Contains(errMsg, "timeout exceeded") - - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - strings.Contains(errMsg, "context window") || - strings.Contains(errMsg, "context_window") || - strings.Contains(errMsg, "maximum context length") || - strings.Contains(errMsg, "token limit") || - strings.Contains(errMsg, "too many tokens") || - strings.Contains(errMsg, "max_tokens") || - strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "prompt is too long") || - strings.Contains(errMsg, "request too large")) - - if isTimeoutError && retry < maxRetries { - backoff := time.Duration(retry+1) * 5 * time.Second - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "timeout", - Error: err.Error(), - Backoff: backoff, - }, - ) - logger.WarnCF("agent", "Timeout 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() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - err = sleepErr - break - } - continue - } - - if isContextError && retry < maxRetries && !ts.opts.NoHistory { - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "context_limit", - Error: err.Error(), - }, - ) - logger.WarnCF( - "agent", - "Context window error detected, attempting compression", - map[string]any{ - "error": err.Error(), - "retry": retry, - }, - ) - - if retry == 0 && !constants.IsInternalChannel(ts.channel) { - al.bus.PublishOutbound(ctx, outboundMessageForTurn( - ts, - "Context window exceeded. Compressing history and retrying...", - )) - } - - if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonRetry, - Budget: ts.agent.ContextWindow, - }); compactErr != nil { - logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ - "session_key": ts.sessionKey, - "error": compactErr.Error(), - }) - } - ts.refreshRestorePointFromSession(ts.agent) - // Re-assemble from CM after compact. - if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); asmErr == nil && asmResp != nil { - history = asmResp.History - summary = asmResp.Summary - } - messages = ts.agent.ContextBuilder.BuildMessages( - history, summary, "", - nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - callMessages = messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - } - continue - } - break - } - - if err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "llm", - Message: err.Error(), - }, - ) - logger.ErrorCF("agent", "LLM call failed", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "model": llmModel, - "error": err.Error(), - }) - return turnResult{}, fmt.Errorf("LLM call failed after retries: %w", err) - } - - if al.hooks != nil { - llmResp, decision := al.hooks.AfterLLM(turnCtx, &LLMHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.llm.response"), - Context: cloneTurnContext(ts.turnCtx), - Model: llmModel, - Response: response, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmResp != nil && llmResp.Response != nil { - response = llmResp.Response - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - // Save finishReason to turnState for SubTurn truncation detection - if innerTS := turnStateFromContext(ctx); innerTS != nil { - innerTS.SetLastFinishReason(response.FinishReason) - // Save usage for token budget tracking - if response.Usage != nil { - innerTS.SetLastUsage(response.Usage) - } - } - - reasoningContent := response.Reasoning - if reasoningContent == "" { - reasoningContent = response.ReasoningContent - } - if ts.channel == "pico" { - go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) - } else { - go al.handleReasoning( - turnCtx, - reasoningContent, - ts.channel, - al.targetReasoningChannelID(ts.channel), - ) - } - al.emitEvent( - EventKindLLMResponse, - ts.eventMeta("runTurn", "turn.llm.response"), - LLMResponsePayload{ - ContentLen: len(response.Content), - ToolCalls: len(response.ToolCalls), - HasReasoning: response.Reasoning != "" || response.ReasoningContent != "", - }, - ) - - llmResponseFields := map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_chars": len(response.Content), - "tool_calls": len(response.ToolCalls), - "reasoning": response.Reasoning, - "target_channel": al.targetReasoningChannelID(ts.channel), - "channel": ts.channel, - } - if response.Usage != nil { - llmResponseFields["prompt_tokens"] = response.Usage.PromptTokens - llmResponseFields["completion_tokens"] = response.Usage.CompletionTokens - llmResponseFields["total_tokens"] = response.Usage.TotalTokens - } - logger.DebugCF("agent", "LLM response", llmResponseFields) - - if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { - if strings.TrimSpace(response.Content) != "" { - outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) - err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: response.Content, - }) - outCancel() - if err != nil { - logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{ - "error": err.Error(), - "channel": ts.channel, - "chat_id": ts.chatID, - "iteration": iteration, - }) - } - } - } - - if len(response.ToolCalls) == 0 || gracefulTerminal { - responseContent := response.Content - if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" { - responseContent = response.ReasoningContent - } - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "steering_count": len(steerMsgs), - }) - pendingMessages = append(pendingMessages, steerMsgs...) - continue - } - finalContent = responseContent - logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_chars": len(finalContent), - }) - break - } - - normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) - } - - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { - toolNames = append(toolNames, tc.Name) - } - logger.InfoCF("agent", "LLM requested tool calls", - map[string]any{ - "agent_id": ts.agent.ID, - "tools": toolNames, - "count": len(normalizedToolCalls), - "iteration": iteration, - }) - - allResponsesHandled := len(normalizedToolCalls) > 0 - assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, - ReasoningContent: response.ReasoningContent, - } - for _, tc := range normalizedToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) - extraContent := tc.ExtraContent - thoughtSignature := "" - if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Name: tc.Name, - Function: &providers.FunctionCall{ - Name: tc.Name, - Arguments: string(argumentsJSON), - ThoughtSignature: thoughtSignature, - }, - ExtraContent: extraContent, - ThoughtSignature: thoughtSignature, - }) - } - messages = append(messages, assistantMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) - ts.recordPersistedMessage(assistantMsg) - ts.ingestMessage(turnCtx, al, assistantMsg) - } - - ts.setPhase(TurnPhaseTools) - for i, tc := range normalizedToolCalls { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - toolName := tc.Name - toolArgs := cloneStringAnyMap(tc.Arguments) - - if al.hooks != nil { - toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ - Meta: ts.eventMeta("runTurn", "turn.tool.before"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolReq != nil { - toolName = toolReq.Tool - toolArgs = toolReq.Arguments - } - case HookActionRespond: - // Hook returns result directly, skip tool execution. - // SECURITY: This bypasses ApproveTool, allowing hooks to respond - // for any tool name without approval. This is intentional for - // plugin tools but means a before_tool hook can override even - // sensitive tools like bash. Hook configuration should be - // carefully reviewed to prevent unauthorized tool execution. - if toolReq != nil && toolReq.HookResult != nil { - hookResult := toolReq.HookResult - - argsJSON, _ := json.Marshal(toolArgs) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "iteration": iteration, - }) - - // Emit ToolExecStart event (same as normal tool execution) - al.emitEvent( - EventKindToolExecStart, - ts.eventMeta("runTurn", "turn.tool.start"), - ToolExecStartPayload{ - Tool: toolName, - Arguments: cloneEventArguments(toolArgs), - }, - ) - - // Send tool feedback to chat channel if enabled (same as normal tool execution) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - argsJSON, _ := json.Marshal(toolArgs) - feedbackPreview := utils.Truncate( - string(argsJSON), - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) - fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: feedbackMsg, - }) - fbCancel() - } - - toolDuration := time.Duration(0) // Hook execution time unknown - - // Send ForUser content to user - // For ResponseHandled results, send regardless of SendResponse setting, - // same as normal tool execution path. - shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && - (ts.opts.SendResponse || hookResult.ResponseHandled) - if shouldSendForUser { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Context: bus.InboundContext{ - Channel: ts.channel, - ChatID: ts.chatID, - Raw: map[string]string{ - "is_tool_call": "true", - }, - }, - Content: hookResult.ForUser, - }) - } - - // Handle media from hook result (same as normal tool execution) - if len(hookResult.Media) > 0 && hookResult.ResponseHandled { - parts := make([]bus.MediaPart, 0, len(hookResult.Media)) - for _, ref := range hookResult.Media { - part := bus.MediaPart{Ref: ref} - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - parts = append(parts, part) - } - outboundMedia := bus.OutboundMediaMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Parts: parts, - } - if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { - if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { - logger.WarnCF("agent", "Failed to deliver hook media", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "channel": ts.channel, - "chat_id": ts.chatID, - "error": err.Error(), - }) - // Same as normal tool execution: notify LLM about delivery failure - hookResult.IsError = true - hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) - } - } else if al.bus != nil { - al.bus.PublishOutboundMedia(ctx, outboundMedia) - // Same as normal tool execution: bus only queues, media not yet delivered - hookResult.ResponseHandled = false - } - } - - // Track response handling status (same as normal tool execution) - if !hookResult.ResponseHandled { - allResponsesHandled = false - } - - // Build tool message - contentForLLM := hookResult.ContentForLLM() - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: tc.ID, - } - - // Handle media for LLM vision (same as normal tool execution) - if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { - hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) - // Recalculate contentForLLM after adding ArtifactTags - contentForLLM = hookResult.ContentForLLM() - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - toolResultMsg.Content = contentForLLM - toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) - } - - // Emit ToolExecEnd event (after filtering, same as normal tool execution) - al.emitEvent( - EventKindToolExecEnd, - ts.eventMeta("runTurn", "turn.tool.end"), - ToolExecEndPayload{ - Tool: toolName, - Duration: toolDuration, - ForLLMLen: len(contentForLLM), - ForUserLen: len(hookResult.ForUser), - IsError: hookResult.IsError, - Async: hookResult.Async, - }, - ) - - messages = append(messages, toolResultMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) - ts.recordPersistedMessage(toolResultMsg) - ts.ingestMessage(turnCtx, al, toolResultMsg) - } - - // Same as normal tool execution: check for steering/interrupt/SubTurn after each tool - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - - skipReason := "" - skipMessage := "" - if len(pendingMessages) > 0 { - skipReason = "queued user steering message" - skipMessage = "Skipped due to queued user message." - } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { - skipReason = "graceful interrupt requested" - skipMessage = "Skipped due to graceful interrupt." - } - - if skipReason != "" { - remaining := len(normalizedToolCalls) - i - 1 - if remaining > 0 { - logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", - map[string]any{ - "agent_id": ts.agent.ID, - "completed": i + 1, - "skipped": remaining, - "reason": skipReason, - }) - for j := i + 1; j < len(normalizedToolCalls); j++ { - skippedTC := normalizedToolCalls[j] - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: skippedTC.Name, - Reason: skipReason, - }, - ) - skippedMsg := providers.Message{ - Role: "tool", - Content: skipMessage, - ToolCallID: skippedTC.ID, - } - messages = append(messages, skippedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) - ts.recordPersistedMessage(skippedMsg) - } - } - } - break - } - - // Also poll for any SubTurn results that arrived during tool execution. - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) - } - default: - // No results available - } - } - - continue - } - // If no HookResult, fall back to continue with warning - logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "action": "respond", - }) - case HookActionDenyTool: - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: toolName, - Reason: denyContent, - }, - ) - deniedMsg := providers.Message{ - Role: "tool", - Content: denyContent, - ToolCallID: tc.ID, - } - messages = append(messages, deniedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) - ts.recordPersistedMessage(deniedMsg) - } - continue - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - if al.hooks != nil { - approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ - Meta: ts.eventMeta("runTurn", "turn.tool.approve"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - }) - if !approval.Approved { - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: toolName, - Reason: denyContent, - }, - ) - deniedMsg := providers.Message{ - Role: "tool", - Content: denyContent, - ToolCallID: tc.ID, - } - messages = append(messages, deniedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) - ts.recordPersistedMessage(deniedMsg) - } - continue - } - } - - argsJSON, _ := json.Marshal(toolArgs) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "iteration": iteration, - }) - al.emitEvent( - EventKindToolExecStart, - ts.eventMeta("runTurn", "turn.tool.start"), - ToolExecStartPayload{ - Tool: toolName, - Arguments: cloneEventArguments(toolArgs), - }, - ) - - // Send tool feedback to chat channel if enabled (from HEAD) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - feedbackPreview := utils.Truncate( - string(argsJSON), - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) - feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) - fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) - fbCancel() - } - - toolCallID := tc.ID - toolIteration := iteration - asyncToolName := toolName - asyncCallback := func(_ context.Context, result *tools.ToolResult) { - // Send ForUser content directly to the user (immediate feedback), - // mirroring the synchronous tool execution path. - if !result.Silent && result.ForUser != "" { - outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer outCancel() - _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) - } - - // Determine content for the agent loop (ForLLM or error). - content := result.ContentForLLM() - if content == "" { - return - } - - // Filter sensitive data before publishing - content = al.cfg.FilterSensitiveData(content) - - logger.InfoCF("agent", "Async tool completed, publishing result", - map[string]any{ - "tool": asyncToolName, - "content_len": len(content), - "channel": ts.channel, - }) - al.emitEvent( - EventKindFollowUpQueued, - ts.scope.meta(toolIteration, "runTurn", "turn.follow_up.queued"), - FollowUpQueuedPayload{ - SourceTool: asyncToolName, - ContentLen: len(content), - }, - ) - - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Context: bus.InboundContext{ - Channel: "system", - ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), - ChatType: "direct", - SenderID: fmt.Sprintf("async:%s", asyncToolName), - }, - Content: content, - }) - } - - toolStart := time.Now() - execCtx := tools.WithToolInboundContext( - turnCtx, - ts.channel, - ts.chatID, - ts.opts.Dispatch.MessageID(), - ts.opts.Dispatch.ReplyToMessageID(), - ) - execCtx = tools.WithToolSessionContext( - execCtx, - ts.agent.ID, - ts.sessionKey, - ts.opts.Dispatch.SessionScope, - ) - toolResult := ts.agent.Tools.ExecuteWithContext( - execCtx, - toolName, - toolArgs, - ts.channel, - ts.chatID, - asyncCallback, - ) - toolDuration := time.Since(toolStart) - - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - if al.hooks != nil { - toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.tool.after"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - Result: toolResult, - Duration: toolDuration, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolResp != nil { - if toolResp.Tool != "" { - toolName = toolResp.Tool - } - if toolResp.Result != nil { - toolResult = toolResp.Result - } - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - if toolResult == nil { - toolResult = tools.ErrorResult("hook returned nil tool result") - } - - if len(toolResult.Media) > 0 && toolResult.ResponseHandled { - parts := make([]bus.MediaPart, 0, len(toolResult.Media)) - for _, ref := range toolResult.Media { - part := bus.MediaPart{Ref: ref} - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - parts = append(parts, part) - } - outboundMedia := bus.OutboundMediaMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Context: outboundContextFromInbound( - ts.opts.Dispatch.InboundContext, - ts.channel, - ts.chatID, - ts.opts.Dispatch.ReplyToMessageID(), - ), - AgentID: ts.agent.ID, - SessionKey: ts.sessionKey, - Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), - Parts: parts, - } - if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { - if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { - logger.WarnCF("agent", "Failed to deliver handled tool media", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "channel": ts.channel, - "chat_id": ts.chatID, - "error": err.Error(), - }) - toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) - } - } else if al.bus != nil { - al.bus.PublishOutboundMedia(ctx, outboundMedia) - // Queuing media is only best-effort; it has not been delivered yet. - toolResult.ResponseHandled = false - } - } - - if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { - // For tools like load_image that produce media refs without sending them - // to the user channel (ResponseHandled == false), both Media and ArtifactTags - // coexist on the result: - // - Media: carries media:// refs that resolveMediaRefs will base64-encode - // into image_url parts in the next LLM iteration (enabling vision). - // - ArtifactTags: exposes the local file path as a structured [file:…] tag - // in the tool result text, so the LLM knows an artifact was produced. - toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) - } - - if !toolResult.ResponseHandled { - allResponsesHandled = false - } - - shouldSendForUser := !toolResult.Silent && - toolResult.ForUser != "" && - (ts.opts.SendResponse || toolResult.ResponseHandled) - if shouldSendForUser { - al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) - logger.DebugCF("agent", "Sent tool result to user", - map[string]any{ - "tool": toolName, - "content_len": len(toolResult.ForUser), - }) - } - contentForLLM := toolResult.ContentForLLM() - - // Filter sensitive data (API keys, tokens, secrets) before sending to LLM - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: toolCallID, - } - if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { - toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) - } - al.emitEvent( - EventKindToolExecEnd, - ts.eventMeta("runTurn", "turn.tool.end"), - ToolExecEndPayload{ - Tool: toolName, - Duration: toolDuration, - ForLLMLen: len(contentForLLM), - ForUserLen: len(toolResult.ForUser), - IsError: toolResult.IsError, - Async: toolResult.Async, - }, - ) - messages = append(messages, toolResultMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) - ts.recordPersistedMessage(toolResultMsg) - ts.ingestMessage(turnCtx, al, toolResultMsg) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - - skipReason := "" - skipMessage := "" - if len(pendingMessages) > 0 { - skipReason = "queued user steering message" - skipMessage = "Skipped due to queued user message." - } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { - skipReason = "graceful interrupt requested" - skipMessage = "Skipped due to graceful interrupt." - } - - if skipReason != "" { - remaining := len(normalizedToolCalls) - i - 1 - if remaining > 0 { - logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", - map[string]any{ - "agent_id": ts.agent.ID, - "completed": i + 1, - "skipped": remaining, - "reason": skipReason, - }) - for j := i + 1; j < len(normalizedToolCalls); j++ { - skippedTC := normalizedToolCalls[j] - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: skippedTC.Name, - Reason: skipReason, - }, - ) - skippedMsg := providers.Message{ - Role: "tool", - Content: skipMessage, - ToolCallID: skippedTC.ID, - } - messages = append(messages, skippedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) - ts.recordPersistedMessage(skippedMsg) - } - } - } - break - } - - // Also poll for any SubTurn results that arrived during tool execution. - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) - } - default: - // No results available - } - } - } - - if allResponsesHandled { - if len(pendingMessages) > 0 { - logger.InfoCF("agent", "Pending steering exists after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(pendingMessages), - "session_key": ts.sessionKey, - }) - finalContent = "" - goto turnLoop - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - summaryMsg := providers.Message{ - Role: "assistant", - Content: handledToolResponseSummary, - } - - if !ts.opts.NoHistory { - ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) - ts.recordPersistedMessage(summaryMsg) - ts.ingestMessage(turnCtx, al, summaryMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - if ts.opts.EnableSummary { - al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, Budget: ts.agent.ContextWindow}) - } - - ts.setPhase(TurnPhaseCompleted) - ts.setFinalContent("") - logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "tool_count": len(normalizedToolCalls), - }) - return turnResult{ - finalContent: "", - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil - } - - ts.agent.Tools.TickTTL() - logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ - "agent_id": ts.agent.ID, "iteration": iteration, - }) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - if finalContent == "" { - if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { - finalContent = toolLimitResponse - } else { - finalContent = ts.opts.DefaultResponse - } - } - - ts.setPhase(TurnPhaseFinalizing) - ts.setFinalContent(finalContent) - if !ts.opts.NoHistory { - finalMsg := providers.Message{Role: "assistant", Content: finalContent} - ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) - ts.recordPersistedMessage(finalMsg) - ts.ingestMessage(turnCtx, al, finalMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - - if ts.opts.EnableSummary { - al.contextManager.Compact( - turnCtx, - &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonSummarize, - Budget: ts.agent.ContextWindow, - }, - ) - } - - ts.setPhase(TurnPhaseCompleted) - return turnResult{ - finalContent: finalContent, - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil -} - -func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { - ts.setPhase(TurnPhaseAborted) - if !ts.opts.NoHistory { - if err := ts.restoreSession(ts.agent); err != nil { - al.emitEvent( - EventKindError, - ts.eventMeta("abortTurn", "turn.error"), - ErrorPayload{ - Stage: "session_restore", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - return turnResult{status: TurnEndStatusAborted}, nil -} - -func sleepWithContext(ctx context.Context, d time.Duration) error { - timer := time.NewTimer(d) - defer timer.Stop() - - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -// selectCandidates returns the model candidates and resolved model name to use -// for a conversation turn. When model routing is configured and the incoming -// message scores below the complexity threshold, it returns the light model -// candidates instead of the primary ones. -// -// The returned (candidates, model) pair is used for all LLM calls within one -// turn — tool follow-up iterations use the same tier as the initial call so -// that a multi-step tool chain doesn't switch models mid-way. -func (al *AgentLoop) selectCandidates( - agent *AgentInstance, - userMsg string, - history []providers.Message, -) (candidates []providers.FallbackCandidate, model string, usedLight bool) { - if agent.Router == nil || len(agent.LightCandidates) == 0 { - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false - } - - _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) - if !usedLight { - logger.DebugCF("agent", "Model routing: primary model selected", - map[string]any{ - "agent_id": agent.ID, - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false - } - - logger.InfoCF("agent", "Model routing: light model selected", - map[string]any{ - "agent_id": agent.ID, - "light_model": agent.Router.LightModel(), - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true -} - -// resolveContextManager selects the ContextManager implementation based on config. -func (al *AgentLoop) resolveContextManager() ContextManager { - name := al.cfg.Agents.Defaults.ContextManager - if name == "" || name == "legacy" { - return &legacyContextManager{al: al} - } - factory, ok := lookupContextManager(name) - if !ok { - logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ - "name": name, - }) - return &legacyContextManager{al: al} - } - cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) - if err != nil { - logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ - "name": name, - "error": err.Error(), - }) - return &legacyContextManager{al: al} - } - return cm -} - -// GetStartupInfo returns information about loaded tools and skills for logging. -func (al *AgentLoop) GetStartupInfo() map[string]any { - info := make(map[string]any) - - registry := al.GetRegistry() - agent := registry.GetDefaultAgent() - if agent == nil { - return info - } - - // Tools info - toolsList := agent.Tools.List() - info["tools"] = map[string]any{ - "count": len(toolsList), - "names": toolsList, - } - - // Skills info - info["skills"] = agent.ContextBuilder.GetSkillsInfo() - - // Agents info - info["agents"] = map[string]any{ - "count": len(registry.ListAgentIDs()), - "ids": registry.ListAgentIDs(), - } - - return info -} - -// formatMessagesForLog formats messages for logging -func formatMessagesForLog(messages []providers.Message) string { - if len(messages) == 0 { - return "[]" - } - - var sb strings.Builder - sb.WriteString("[\n") - for i, msg := range messages { - fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role) - if len(msg.ToolCalls) > 0 { - sb.WriteString(" ToolCalls:\n") - for _, tc := range msg.ToolCalls { - fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) - if tc.Function != nil { - fmt.Fprintf( - &sb, - " Arguments: %s\n", - utils.Truncate(tc.Function.Arguments, 200), - ) - } - } - } - if msg.Content != "" { - content := utils.Truncate(msg.Content, 200) - fmt.Fprintf(&sb, " Content: %s\n", content) - } - if msg.ToolCallID != "" { - fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID) - } - sb.WriteString("\n") - } - sb.WriteString("]") - return sb.String() -} - -// formatToolsForLog formats tool definitions for logging -func formatToolsForLog(toolDefs []providers.ToolDefinition) string { - if len(toolDefs) == 0 { - return "[]" - } - - var sb strings.Builder - sb.WriteString("[\n") - for i, tool := range toolDefs { - fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) - fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) - if len(tool.Function.Parameters) > 0 { - fmt.Fprintf( - &sb, - " Parameters: %s\n", - utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200), - ) - } - } - sb.WriteString("]") - return sb.String() -} - -// summarizeSession summarizes the conversation history for a session. -// findNearestUserMessage finds the nearest user message to the given index. -// It searches backward first, then forward if no user message is found. -// retryLLMCall calls the LLM with retry logic. -// summarizeBatch summarizes a batch of messages. -// estimateTokens estimates the number of tokens in a message list. -// Counts Content, ToolCalls arguments, and ToolCallID metadata so that -// tool-heavy conversations are not systematically undercounted. -func (al *AgentLoop) handleCommand( - ctx context.Context, - msg bus.InboundMessage, - agent *AgentInstance, - opts *processOptions, -) (string, bool) { - normalizeProcessOptionsInPlace(opts) - - if !commands.HasCommandPrefix(msg.Content) { - return "", false - } - - if matched, handled, reply := al.applyExplicitSkillCommand(msg.Content, agent, opts); matched { - return reply, handled - } - - if al.cmdRegistry == nil { - return "", false - } - - rt := al.buildCommandsRuntime(ctx, agent, opts) - executor := commands.NewExecutor(al.cmdRegistry, rt) - - var commandReply string - result := executor.Execute(ctx, commands.Request{ - Channel: msg.Channel, - ChatID: msg.ChatID, - SenderID: msg.SenderID, - Text: msg.Content, - Reply: func(text string) error { - commandReply = text - return nil - }, - }) - - switch result.Outcome { - case commands.OutcomeHandled: - if result.Err != nil { - return mapCommandError(result), true - } - if commandReply != "" { - return commandReply, true - } - return "", true - default: // OutcomePassthrough — let the message fall through to LLM - return "", false - } -} - -func activeSkillNames(agent *AgentInstance, opts processOptions) []string { - if agent == nil { - return nil - } - - combined := make([]string, 0, len(agent.SkillsFilter)+len(opts.ForcedSkills)) - combined = append(combined, agent.SkillsFilter...) - combined = append(combined, opts.ForcedSkills...) - if len(combined) == 0 { - return nil - } - - var resolved []string - seen := make(map[string]struct{}, len(combined)) - for _, name := range combined { - name = strings.TrimSpace(name) - if name == "" { - continue - } - if agent.ContextBuilder != nil { - if canonical, ok := agent.ContextBuilder.ResolveSkillName(name); ok { - name = canonical - } - } - key := strings.ToLower(name) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - resolved = append(resolved, name) - } - - return resolved -} - -func (al *AgentLoop) applyExplicitSkillCommand( - raw string, - agent *AgentInstance, - opts *processOptions, -) (matched bool, handled bool, reply string) { - normalizeProcessOptionsInPlace(opts) - - cmdName, ok := commands.CommandName(raw) - if !ok || cmdName != "use" { - return false, false, "" - } - - if agent == nil || agent.ContextBuilder == nil { - return true, true, commandsUnavailableSkillMessage() - } - - parts := strings.Fields(strings.TrimSpace(raw)) - if len(parts) < 2 { - return true, true, buildUseCommandHelp(agent) - } - - arg := strings.TrimSpace(parts[1]) - if strings.EqualFold(arg, "clear") || strings.EqualFold(arg, "off") { - if opts != nil { - al.clearPendingSkills(opts.Dispatch.SessionKey) - } - return true, true, "Cleared pending skill override." - } - - skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) - if !ok { - return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) - } - - if len(parts) < 3 { - if opts == nil || strings.TrimSpace(opts.Dispatch.SessionKey) == "" { - return true, true, commandsUnavailableSkillMessage() - } - al.setPendingSkills(opts.Dispatch.SessionKey, []string{skillName}) - return true, true, fmt.Sprintf( - "Skill %q is armed for your next message. Send your next prompt normally, or use /use clear to cancel.", - skillName, - ) - } - - message := strings.TrimSpace(strings.Join(parts[2:], " ")) - if message == "" { - return true, true, buildUseCommandHelp(agent) - } - - if opts != nil { - opts.ForcedSkills = append(opts.ForcedSkills, skillName) - opts.Dispatch.UserMessage = message - opts.UserMessage = message - } - - return true, false, "" -} - -func (al *AgentLoop) buildCommandsRuntime( - ctx context.Context, - agent *AgentInstance, - opts *processOptions, -) *commands.Runtime { - normalizeProcessOptionsInPlace(opts) - - registry := al.GetRegistry() - cfg := al.GetConfig() - rt := &commands.Runtime{ - Config: cfg, - ListAgentIDs: registry.ListAgentIDs, - ListDefinitions: al.cmdRegistry.Definitions, - GetEnabledChannels: func() []string { - if al.channelManager == nil { - return nil - } - return al.channelManager.GetEnabledChannels() - }, - GetActiveTurn: func() any { - info := al.GetActiveTurn() - if info == nil { - return nil - } - return info - }, - SwitchChannel: func(value string) error { - if al.channelManager == nil { - return fmt.Errorf("channel manager not initialized") - } - if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Errorf("channel '%s' not found or not enabled", value) - } - return nil - }, - } - if agent != nil && agent.ContextBuilder != nil { - rt.ListSkillNames = agent.ContextBuilder.ListSkillNames - } - rt.ReloadConfig = func() error { - if al.reloadFunc == nil { - return fmt.Errorf("reload not configured") - } - return al.reloadFunc() - } - if agent != nil { - if agent.ContextBuilder != nil { - rt.ListSkillNames = agent.ContextBuilder.ListSkillNames - } - rt.GetModelInfo = func() (string, string) { - return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) - } - rt.SwitchModel = func(value string) (string, error) { - value = strings.TrimSpace(value) - modelCfg, err := resolvedModelConfig(cfg, value, agent.Workspace) - if err != nil { - return "", err - } - - nextProvider, _, err := providers.CreateProviderFromConfig(modelCfg) - if err != nil { - return "", fmt.Errorf("failed to initialize model %q: %w", value, err) - } - - nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks) - if len(nextCandidates) == 0 { - return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) - } - - oldModel := agent.Model - oldProvider := agent.Provider - agent.Model = value - agent.Provider = nextProvider - agent.Candidates = nextCandidates - agent.ThinkingLevel = parseThinkingLevel(modelCfg.ThinkingLevel) - - if oldProvider != nil && oldProvider != nextProvider { - if stateful, ok := oldProvider.(providers.StatefulProvider); ok { - stateful.Close() - } - } - return oldModel, nil - } - - rt.ClearHistory = func() error { - if opts == nil { - return fmt.Errorf("process options not available") - } - return al.contextManager.Clear(ctx, opts.SessionKey) - } - } - return rt -} - -func commandsUnavailableSkillMessage() string { - return "Skill selection is unavailable in the current context." -} - -func buildUseCommandHelp(agent *AgentInstance) string { - if agent == nil || agent.ContextBuilder == nil { - return "Usage: /use [message]" - } - - names := agent.ContextBuilder.ListSkillNames() - if len(names) == 0 { - return "Usage: /use [message]\nNo installed skills found." - } - - return fmt.Sprintf( - "Usage: /use [message]\n\nInstalled Skills:\n- %s\n\nUse /use to apply a skill to your next message, or /use to force it immediately.", - strings.Join(names, "\n- "), - ) -} - -func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { - sessionKey = strings.TrimSpace(sessionKey) - if sessionKey == "" || len(skillNames) == 0 { - return - } - - filtered := make([]string, 0, len(skillNames)) - for _, name := range skillNames { - name = strings.TrimSpace(name) - if name != "" { - filtered = append(filtered, name) - } - } - if len(filtered) == 0 { - return - } - - al.pendingSkills.Store(sessionKey, filtered) -} - -func (al *AgentLoop) takePendingSkills(sessionKey string) []string { - sessionKey = strings.TrimSpace(sessionKey) - if sessionKey == "" { - return nil - } - - value, ok := al.pendingSkills.LoadAndDelete(sessionKey) - if !ok { - return nil - } - - skills, ok := value.([]string) - if !ok { - return nil - } - - return append([]string(nil), skills...) -} - -func (al *AgentLoop) clearPendingSkills(sessionKey string) { - sessionKey = strings.TrimSpace(sessionKey) - if sessionKey == "" { - return - } - al.pendingSkills.Delete(sessionKey) -} - -func mapCommandError(result commands.ExecuteResult) string { - if result.Command == "" { - return fmt.Sprintf("Failed to execute command: %v", result.Err) - } - return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err) -} - -// isNativeSearchProvider reports whether the given LLM provider implements -// NativeSearchCapable and returns true for SupportsNativeSearch. -func isNativeSearchProvider(p providers.LLMProvider) bool { - if ns, ok := p.(providers.NativeSearchCapable); ok { - return ns.SupportsNativeSearch() - } - return false -} - -// filterClientWebSearch returns a copy of tools with the client-side -// web_search tool removed. Used when native provider search is preferred. -func filterClientWebSearch(tools []providers.ToolDefinition) []providers.ToolDefinition { - result := make([]providers.ToolDefinition, 0, len(tools)) - for _, t := range tools { - if strings.EqualFold(t.Function.Name, "web_search") { - continue - } - result = append(result, t) - } - return result -} - -// Helper to extract provider from registry for cleanup -func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) { - if registry == nil { - return nil, false - } - // Get any agent to access the provider - defaultAgent := registry.GetDefaultAgent() - if defaultAgent == nil { - return nil, false - } - return defaultAgent.Provider, true -} diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 7cbf3a8d6..6065f6403 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -37,14 +37,14 @@ func candidateFromModelConfig( return providers.FallbackCandidate{}, false } - ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider) - if ref == nil { + protocol, modelID := providers.ExtractProtocol(mc) + if strings.TrimSpace(modelID) == "" { return providers.FallbackCandidate{}, false } return providers.FallbackCandidate{ - Provider: ref.Provider, - Model: ref.Model, + Provider: protocol, + Model: modelID, RPM: mc.RPM, IdentityKey: modelConfigIdentityKey(mc), }, true @@ -60,6 +60,12 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig return mc } + rawRef := providers.ParseModelRef(raw, "") + rawKey := "" + if rawRef != nil && strings.TrimSpace(rawRef.Provider) != "" && strings.TrimSpace(rawRef.Model) != "" { + rawKey = providers.ModelKey(rawRef.Provider, rawRef.Model) + } + for i := range cfg.ModelList { mc := cfg.ModelList[i] if mc == nil { @@ -72,10 +78,13 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig if fullModel == raw { return mc } - _, modelID := providers.ExtractProtocol(fullModel) + protocol, modelID := providers.ExtractProtocol(mc) if modelID == raw { return mc } + if rawKey != "" && providers.ModelKey(protocol, modelID) == rawKey { + return mc + } } return nil diff --git a/pkg/agent/pipeline.go b/pkg/agent/pipeline.go new file mode 100644 index 000000000..c4b9ec3af --- /dev/null +++ b/pkg/agent/pipeline.go @@ -0,0 +1,40 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Pipeline holds the runtime dependencies used by Pipeline methods. +// It is constructed by runTurn via NewPipeline and passed to sub-methods +// so that the coordinator can delegate phase execution. +type Pipeline struct { + Bus interfaces.MessageBus + Cfg *config.Config + ContextManager ContextManager + Hooks *HookManager + Fallback *providers.FallbackChain + ChannelManager interfaces.ChannelManager + MediaStore media.MediaStore + Steering any // TODO: *Steering + al *AgentLoop +} + +// NewPipeline creates a Pipeline from an AgentLoop instance. +func NewPipeline(al *AgentLoop) *Pipeline { + return &Pipeline{ + Bus: al.bus, + Cfg: al.GetConfig(), + ContextManager: al.contextManager, + Hooks: al.hooks, + Fallback: al.fallback, + ChannelManager: al.channelManager, + MediaStore: al.mediaStore, + Steering: al.steering, + al: al, + } +} diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go new file mode 100644 index 000000000..9935f2c9e --- /dev/null +++ b/pkg/agent/pipeline_execute.go @@ -0,0 +1,724 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks, +// tool execution with async callbacks, media delivery, and steering injection. +// Returns ToolControl indicating what the coordinator should do next: +// - ToolControlContinue: all tool results handled, pendingMessages or steering exists, continue turn +// - ToolControlBreak: tool loop exited, proceed to coordinator's hardAbort/finalContent/finalize +func (p *Pipeline) ExecuteTools( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) ToolControl { + al := p.al + normalizedToolCalls := exec.normalizedToolCalls + + ts.setPhase(TurnPhaseTools) + messages := exec.messages + handledAttachments := make([]providers.Attachment, 0) + +toolLoop: + for i, tc := range normalizedToolCalls { + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + toolName := tc.Name + toolArgs := cloneStringAnyMap(tc.Arguments) + + if al.hooks != nil { + toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.before"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolReq != nil { + toolName = toolReq.Tool + toolArgs = toolReq.Arguments + } + case HookActionRespond: + if toolReq != nil && toolReq.HookResult != nil { + hookResult := toolReq.HookResult + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + + al.emitEvent( + EventKindToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + messages, + ) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + fbCancel() + } + + toolDuration := time.Duration(0) + + shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && + (ts.opts.SendResponse || hookResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: bus.InboundContext{ + Channel: ts.channel, + ChatID: ts.chatID, + Raw: map[string]string{ + "is_tool_call": "true", + }, + }, + Content: hookResult.ForUser, + }) + } + + if len(hookResult.Media) > 0 && hookResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(hookResult.Media)) + for _, ref := range hookResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: ts.agent.ID, + SessionKey: ts.sessionKey, + Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver hook media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + hookResult.IsError = true + hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, hookResult.Media)..., + ) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + hookResult.ResponseHandled = false + } + } + + if !hookResult.ResponseHandled { + exec.allResponsesHandled = false + } + + contentForLLM := hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: tc.ID, + } + + if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { + hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) + contentForLLM = hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + toolResultMsg.Content = contentForLLM + toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) + } + + al.emitEvent( + EventKindToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(hookResult.ForUser), + IsError: hookResult.IsError, + Async: hookResult.Async, + }, + ) + + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := subTurnResultPromptMessage(content) + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + + continue + } + logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "action": "respond", + }) + case HookActionDenyTool: + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if al.hooks != nil { + approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.approve"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + if !approval.Approved { + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + } + } + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + al.emitEvent( + EventKindToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + messages, + ) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + fbCancel() + } + + toolCallID := tc.ID + asyncToolName := toolName + asyncCallback := func(_ context.Context, result *tools.ToolResult) { + if !result.Silent && result.ForUser != "" { + outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer outCancel() + _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) + } + + content := result.ContentForLLM() + if content == "" { + return + } + + content = al.cfg.FilterSensitiveData(content) + + logger.InfoCF("agent", "Async tool completed, publishing result", + map[string]any{ + "tool": asyncToolName, + "content_len": len(content), + "channel": ts.channel, + }) + al.emitEvent( + EventKindFollowUpQueued, + ts.scope.meta(iteration, "runTurn", "turn.follow_up.queued"), + FollowUpQueuedPayload{ + SourceTool: asyncToolName, + ContentLen: len(content), + }, + ) + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "system", + ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), + ChatType: "direct", + SenderID: fmt.Sprintf("async:%s", asyncToolName), + }, + Content: content, + }) + } + + toolStart := time.Now() + execCtx := tools.WithToolInboundContext( + turnCtx, + ts.channel, + ts.chatID, + ts.opts.Dispatch.MessageID(), + ts.opts.Dispatch.ReplyToMessageID(), + ) + execCtx = tools.WithToolSessionContext( + execCtx, + ts.agent.ID, + ts.sessionKey, + ts.opts.Dispatch.SessionScope, + ) + toolResult := ts.agent.Tools.ExecuteWithContext( + execCtx, + toolName, + toolArgs, + ts.channel, + ts.chatID, + asyncCallback, + ) + toolDuration := time.Since(toolStart) + + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + if al.hooks != nil { + toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.tool.after"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + Result: toolResult, + Duration: toolDuration, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolResp != nil { + if toolResp.Tool != "" { + toolName = toolResp.Tool + } + if toolResp.Result != nil { + toolResult = toolResp.Result + } + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if toolResult == nil { + toolResult = tools.ErrorResult("hook returned nil tool result") + } + + if len(toolResult.Media) > 0 && toolResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: ts.agent.ID, + SessionKey: ts.sessionKey, + Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver handled tool media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, toolResult.Media)..., + ) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + toolResult.ResponseHandled = false + } + } + + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) + } + + if !toolResult.ResponseHandled { + exec.allResponsesHandled = false + } + + shouldSendForUser := !toolResult.Silent && + toolResult.ForUser != "" && + (ts.opts.SendResponse || toolResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) + logger.DebugCF("agent", "Sent tool result to user", + map[string]any{ + "tool": toolName, + "content_len": len(toolResult.ForUser), + }) + } + contentForLLM := toolResult.ContentForLLM() + + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: toolCallID, + } + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) + } + al.emitEvent( + EventKindToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(toolResult.ForUser), + IsError: toolResult.IsError, + Async: toolResult.Async, + }, + ) + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := subTurnResultPromptMessage(content) + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + } + + exec.messages = messages + + // Continue if pending steering exists (regardless of allResponsesHandled). + // This covers the case where tools were partially executed and skipped due to steering, + // but one tool had ResponseHandled=false (so allResponsesHandled=false). + if len(exec.pendingMessages) > 0 { + logger.InfoCF("agent", "Pending steering after partial tool execution; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "pending_count": len(exec.pendingMessages), + "allResponsesHandled": exec.allResponsesHandled, + }) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // Poll for newly arrived steering + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after tool delivery; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // No pending steering: finalize or break depending on allResponsesHandled + if exec.allResponsesHandled { + summaryMsg := providers.Message{ + Role: "assistant", + Content: handledToolResponseSummary, + Attachments: append([]providers.Attachment(nil), handledAttachments...), + } + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, summaryMsg) + ts.recordPersistedMessage(summaryMsg) + ts.ingestMessage(turnCtx, al, summaryMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + logger.WarnCF("agent", "Failed to save session after tool delivery", + map[string]any{ + "agent_id": ts.agent.ID, + "error": err.Error(), + }) + } + } + if ts.opts.EnableSummary { + al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }) + } + ts.setPhase(TurnPhaseCompleted) + ts.setFinalContent("") + logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "tool_count": len(normalizedToolCalls), + }) + return ToolControlBreak + } + + // allResponsesHandled=false and no pending steering: continue so coordinator + // makes another LLM call. The tool result is in messages and the LLM will + // return it as finalContent in the next iteration. + ts.agent.Tools.TickTTL() + logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ + "agent_id": ts.agent.ID, "iteration": iteration, + }) + return ToolControlContinue +} diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go new file mode 100644 index 000000000..a2be6f65b --- /dev/null +++ b/pkg/agent/pipeline_finalize.go @@ -0,0 +1,81 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Finalize handles turn finalization, either: +// - Early return when allResponsesHandled=true (ExecuteTools already finalized) +// - Normal finalization for allResponsesHandled=false (sets finalContent, saves session, compact) +func (p *Pipeline) Finalize( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + turnStatus TurnEndStatus, + finalContent string, +) (turnResult, error) { + al := p.al + + // When allResponsesHandled=true, ExecuteTools already finalized + // (added handledToolResponseSummary, saved session, set phase to Completed). + // But still check for hard abort - if requested, abort the turn. + if exec.allResponsesHandled { + if ts.hardAbortRequested() { + return al.abortTurn(ts) + } + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil + } + + ts.setPhase(TurnPhaseFinalizing) + ts.setFinalContent(finalContent) + if !ts.opts.NoHistory { + finalMsg := providers.Message{ + Role: "assistant", + Content: finalContent, + ReasoningContent: responseReasoningContent(exec.response), + } + ts.agent.Sessions.AddFullMessage(ts.sessionKey, finalMsg) + ts.recordPersistedMessage(finalMsg) + ts.ingestMessage(turnCtx, al, finalMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "session_save", + Message: err.Error(), + }, + ) + return turnResult{status: TurnEndStatusError}, err + } + } + + if ts.opts.EnableSummary { + al.contextManager.Compact( + turnCtx, + &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }, + ) + } + + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil +} diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go new file mode 100644 index 000000000..6bf55fa39 --- /dev/null +++ b/pkg/agent/pipeline_llm.go @@ -0,0 +1,523 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// CallLLM performs an LLM call with fallback support, hook invocation, and retry logic. +// It handles PreLLM setup, the actual LLM invocation with retry, and AfterLLM processing. +// Returns Control indicating what the coordinator should do next. +func (p *Pipeline) CallLLM( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) (Control, error) { + al := p.al + maxMediaSize := p.Cfg.Agents.Defaults.GetMaxMediaSize() + + // PreLLM: resolve media refs (except on iteration 1 where user media is already resolved) + if iteration > 1 { + exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize) + } + + // PreLLM: graceful terminal handling + exec.gracefulTerminal, _ = ts.gracefulInterruptRequested() + exec.providerToolDefs = ts.agent.Tools.ToProviderDefs() + + // Native web search support + webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") + exec.useNativeSearch = webSearchEnabled && al.cfg.Tools.Web.PreferNative && + func() bool { + if ns, ok := ts.agent.Provider.(providers.NativeSearchCapable); ok { + return ns.SupportsNativeSearch() + } + return false + }() + + if exec.useNativeSearch { + filtered := make([]providers.ToolDefinition, 0, len(exec.providerToolDefs)) + for _, td := range exec.providerToolDefs { + if td.Function.Name != "web_search" { + filtered = append(filtered, td) + } + } + exec.providerToolDefs = filtered + } + + exec.callMessages = exec.messages + if exec.gracefulTerminal { + exec.callMessages = append(append([]providers.Message(nil), exec.messages...), ts.interruptHintMessage()) + exec.providerToolDefs = nil + ts.markGracefulTerminalUsed() + } + + exec.llmOpts = map[string]any{ + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "prompt_cache_key": ts.agent.ID, + } + if exec.useNativeSearch { + exec.llmOpts["native_search"] = true + } + if ts.agent.ThinkingLevel != ThinkingOff { + if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + exec.llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) + } else { + logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", + map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) + } + } + + exec.llmModel = exec.activeModel + + // BeforeLLM hook + if p.Hooks != nil { + llmReq, decision := p.Hooks.BeforeLLM(turnCtx, &LLMHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.llm.request"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Messages: exec.callMessages, + Tools: exec.providerToolDefs, + Options: exec.llmOpts, + GracefulTerminal: exec.gracefulTerminal, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + exec.llmModel = llmReq.Model + exec.callMessages = llmReq.Messages + exec.providerToolDefs = llmReq.Tools + exec.llmOpts = llmReq.Options + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + al.emitEvent( + EventKindLLMRequest, + ts.eventMeta("runTurn", "turn.llm.request"), + LLMRequestPayload{ + Model: exec.llmModel, + MessagesCount: len(exec.callMessages), + ToolsCount: len(exec.providerToolDefs), + MaxTokens: ts.agent.MaxTokens, + Temperature: ts.agent.Temperature, + }, + ) + + logger.DebugCF("agent", "LLM request", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "messages_count": len(exec.callMessages), + "tools_count": len(exec.providerToolDefs), + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "system_prompt_len": len(exec.callMessages[0].Content), + }) + logger.DebugCF("agent", "Full LLM request", + map[string]any{ + "iteration": iteration, + "messages_json": formatMessagesForLog(exec.callMessages), + "tools_json": formatToolsForLog(exec.providerToolDefs), + }) + + // LLM call closure with fallback support + callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { + providerCtx, providerCancel := context.WithCancel(turnCtx) + ts.setProviderCancel(providerCancel) + defer func() { + providerCancel() + ts.clearProviderCancel(providerCancel) + }() + + al.activeRequests.Add(1) + defer al.activeRequests.Done() + + if len(exec.activeCandidates) > 1 && p.Fallback != nil { + fbResult, fbErr := p.Fallback.Execute( + providerCtx, + exec.activeCandidates, + func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { + candidateProvider := exec.activeProvider + if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { + candidateProvider = cp + } + return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, exec.llmOpts) + }, + ) + if fbErr != nil { + return nil, fbErr + } + if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { + logger.InfoCF( + "agent", + fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, + ) + } + return fbResult.Response, nil + } + return exec.activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, exec.llmModel, exec.llmOpts) + } + + // Retry loop + var err error + maxRetries := 2 + for retry := 0; retry <= maxRetries; retry++ { + exec.response, err = callLLM(exec.callMessages, exec.providerToolDefs) + if err == nil { + break + } + if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + + // Retry without media if vision is unsupported + if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{ + "error": err.Error(), + "retry": retry, + }) + exec.callMessages = stripMessageMedia(exec.callMessages) + if !ts.opts.NoHistory { + exec.history = stripMessageMedia(exec.history) + ts.agent.Sessions.SetHistory(ts.sessionKey, exec.history) + for i := range ts.persistedMessages { + ts.persistedMessages[i].Media = nil + } + ts.refreshRestorePointFromSession(ts.agent) + } + continue + } + + errMsg := strings.ToLower(err.Error()) + isTimeoutError := errors.Is(err, context.DeadlineExceeded) || + strings.Contains(errMsg, "deadline exceeded") || + strings.Contains(errMsg, "client.timeout") || + strings.Contains(errMsg, "timed out") || + strings.Contains(errMsg, "timeout exceeded") + + isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || + strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "context_window") || + strings.Contains(errMsg, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large")) + + if isTimeoutError && retry < maxRetries { + backoff := time.Duration(retry+1) * 5 * time.Second + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "timeout", + Error: err.Error(), + Backoff: backoff, + }, + ) + logger.WarnCF("agent", "Timeout 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, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "context_limit", + Error: err.Error(), + }, + ) + logger.WarnCF( + "agent", + "Context window error detected, attempting compression", + map[string]any{ + "error": err.Error(), + "retry": retry, + }, + ) + + if retry == 0 && !constants.IsInternalChannel(ts.channel) { + al.bus.PublishOutbound(ctx, outboundMessageForTurn( + ts, + "Context window exceeded. Compressing history and retrying...", + )) + } + + if compactErr := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonRetry, + Budget: ts.agent.ContextWindow, + }); compactErr != nil { + logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": compactErr.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if asmResp, asmErr := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); asmErr == nil && asmResp != nil { + exec.history = asmResp.History + exec.summary = asmResp.Summary + } + exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt( + promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil), + ) + exec.callMessages = exec.messages + if exec.gracefulTerminal { + msgs := append([]providers.Message(nil), exec.messages...) + exec.callMessages = append(msgs, ts.interruptHintMessage()) + } + continue + } + break + } + + if err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "llm", + Message: err.Error(), + }, + ) + logger.ErrorCF("agent", "LLM call failed", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "error": err.Error(), + }) + return ControlBreak, fmt.Errorf("LLM call failed after retries: %w", err) + } + + // AfterLLM hook + if p.Hooks != nil { + llmResp, decision := p.Hooks.AfterLLM(turnCtx, &LLMHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.llm.response"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Response: exec.response, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + exec.response = llmResp.Response + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + // Save finishReason to turnState for SubTurn truncation detection + if innerTS := turnStateFromContext(ctx); innerTS != nil { + innerTS.SetLastFinishReason(exec.response.FinishReason) + if exec.response.Usage != nil { + innerTS.SetLastUsage(exec.response.Usage) + } + } + + reasoningContent := responseReasoningContent(exec.response) + shouldPublishPicoToolCallInterim := ts.channel == "pico" && len(exec.response.ToolCalls) > 0 + if shouldPublishPicoToolCallInterim { + // Pico tool-call turns publish their reasoning/content/tool summary as a + // structured sequence after the tool-call payload is normalized below. + } else if ts.channel == "pico" { + go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) + } else { + go al.handleReasoning( + turnCtx, + reasoningContent, + ts.channel, + al.targetReasoningChannelID(ts.channel), + ) + } + al.emitEvent( + EventKindLLMResponse, + ts.eventMeta("runTurn", "turn.llm.response"), + LLMResponsePayload{ + ContentLen: len(exec.response.Content), + ToolCalls: len(exec.response.ToolCalls), + HasReasoning: exec.response.Reasoning != "" || exec.response.ReasoningContent != "", + }, + ) + + llmResponseFields := map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.response.Content), + "tool_calls": len(exec.response.ToolCalls), + "reasoning": exec.response.Reasoning, + "target_channel": al.targetReasoningChannelID(ts.channel), + "channel": ts.channel, + } + if exec.response.Usage != nil { + llmResponseFields["prompt_tokens"] = exec.response.Usage.PromptTokens + llmResponseFields["completion_tokens"] = exec.response.Usage.CompletionTokens + llmResponseFields["total_tokens"] = exec.response.Usage.TotalTokens + } + logger.DebugCF("agent", "LLM response", llmResponseFields) + + // No-tool-call path: steering check and direct response + if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal { + responseContent := exec.response.Content + if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" { + responseContent = exec.response.ReasoningContent + } + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + return ControlContinue, nil + } + exec.finalContent = responseContent + logger.InfoCF("agent", "LLM response without tool calls (direct answer)", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.finalContent), + }) + return ControlBreak, nil + } + + // Tool-call path: normalize and prepare for tool execution + exec.normalizedToolCalls = make([]providers.ToolCall, 0, len(exec.response.ToolCalls)) + for _, tc := range exec.response.ToolCalls { + exec.normalizedToolCalls = append(exec.normalizedToolCalls, providers.NormalizeToolCall(tc)) + } + + toolNames := make([]string, 0, len(exec.normalizedToolCalls)) + for _, tc := range exec.normalizedToolCalls { + toolNames = append(toolNames, tc.Name) + } + logger.InfoCF("agent", "LLM requested tool calls", + map[string]any{ + "agent_id": ts.agent.ID, + "tools": toolNames, + "count": len(exec.normalizedToolCalls), + "iteration": iteration, + }) + + exec.allResponsesHandled = len(exec.normalizedToolCalls) > 0 + assistantMsg := providers.Message{ + Role: "assistant", + Content: exec.response.Content, + ReasoningContent: reasoningContent, + } + for _, tc := range exec.normalizedToolCalls { + argumentsJSON, _ := json.Marshal(tc.Arguments) + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + exec.messages, + ) + extraContent := tc.ExtraContent + if strings.TrimSpace(toolFeedbackExplanation) != "" { + if extraContent == nil { + extraContent = &providers.ExtraContent{} + } + extraContent.ToolFeedbackExplanation = toolFeedbackExplanation + } + thoughtSignature := "" + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ + ID: tc.ID, + Type: "function", + Name: tc.Name, + Function: &providers.FunctionCall{ + Name: tc.Name, + Arguments: string(argumentsJSON), + ThoughtSignature: thoughtSignature, + }, + ExtraContent: extraContent, + ThoughtSignature: thoughtSignature, + }) + } + exec.messages = append(exec.messages, assistantMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) + ts.recordPersistedMessage(assistantMsg) + ts.ingestMessage(turnCtx, al, assistantMsg) + } + if shouldPublishPicoToolCallInterim { + al.publishPicoToolCallInterim( + turnCtx, + ts, + reasoningContent, + exec.response.Content, + assistantMsg.ToolCalls, + ) + } + + return ControlToolLoop, nil +} diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go new file mode 100644 index 000000000..219e4e5de --- /dev/null +++ b/pkg/agent/pipeline_setup.go @@ -0,0 +1,101 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// SetupTurn extracts the one-time initialization phase, returning a +// turnExecution populated with history, messages, and candidate selection. +// It replaces lines 56-145 of the original runTurn. +func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution, error) { + cfg := p.Cfg + maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() + + var history []providers.Message + var summary string + if !ts.opts.NoHistory { + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + ts.captureRestorePoint(history, summary) + + messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt( + promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media), + ) + + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + + if !ts.opts.NoHistory { + toolDefs := ts.agent.Tools.ToProviderDefs() + if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { + logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", + map[string]any{"session_key": ts.sessionKey}) + if err := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonProactive, + Budget: ts.agent.ContextWindow, + }); err != nil { + logger.WarnCF("agent", "Proactive compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt( + promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media), + ) + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + } + } + + if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { + rootMsg := userPromptMessage(ts.userMessage, ts.media) + if len(rootMsg.Media) > 0 { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) + } else { + ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) + } + ts.recordPersistedMessage(rootMsg) + ts.ingestMessage(ctx, p.al, rootMsg) + } + + activeCandidates, activeModel, usedLight := p.al.selectCandidates(ts.agent, ts.userMessage, messages) + activeProvider := ts.agent.Provider + if usedLight && ts.agent.LightProvider != nil { + activeProvider = ts.agent.LightProvider + } + + exec := newTurnExecution( + ts.agent, + ts.opts, + history, + summary, + messages, + ) + exec.activeCandidates = activeCandidates + exec.activeModel = activeModel + exec.activeProvider = activeProvider + exec.usedLight = usedLight + + return exec, nil +} diff --git a/pkg/agent/prompt.go b/pkg/agent/prompt.go new file mode 100644 index 000000000..be5ccddf2 --- /dev/null +++ b/pkg/agent/prompt.go @@ -0,0 +1,496 @@ +package agent + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type PromptLayer string + +const ( + PromptLayerKernel PromptLayer = "kernel" + PromptLayerInstruction PromptLayer = "instruction" + PromptLayerCapability PromptLayer = "capability" + PromptLayerContext PromptLayer = "context" + PromptLayerTurn PromptLayer = "turn" +) + +type PromptSlot string + +const ( + PromptSlotIdentity PromptSlot = "identity" + PromptSlotHierarchy PromptSlot = "hierarchy" + PromptSlotWorkspace PromptSlot = "workspace" + PromptSlotTooling PromptSlot = "tooling" + PromptSlotMCP PromptSlot = "mcp" + PromptSlotSkillCatalog PromptSlot = "skill_catalog" + PromptSlotActiveSkill PromptSlot = "active_skill" + PromptSlotMemory PromptSlot = "memory" + PromptSlotRuntime PromptSlot = "runtime" + PromptSlotSummary PromptSlot = "summary" + PromptSlotMessage PromptSlot = "message" + PromptSlotSteering PromptSlot = "steering" + PromptSlotSubTurn PromptSlot = "subturn" + PromptSlotInterrupt PromptSlot = "interrupt" + PromptSlotOutput PromptSlot = "output" +) + +type PromptSourceID string + +const ( + PromptSourceKernel PromptSourceID = "runtime.kernel" + PromptSourceHierarchy PromptSourceID = "runtime.hierarchy" + PromptSourceWorkspace PromptSourceID = "workspace.definition" + PromptSourceRuntime PromptSourceID = "runtime.context" + PromptSourceSummary PromptSourceID = "context.summary" + PromptSourceMemory PromptSourceID = "memory:workspace" + PromptSourceSkillCatalog PromptSourceID = "skill:index" + PromptSourceActiveSkills PromptSourceID = "skill:active" + PromptSourceToolRegistry PromptSourceID = "tool_registry:native" + PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery" + PromptSourceOutputPolicy PromptSourceID = "runtime.output" + PromptSourceSubTurnProfile PromptSourceID = "subturn.profile" + PromptSourceUserMessage PromptSourceID = "turn:user_message" + PromptSourceSteering PromptSourceID = "turn:steering" + PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result" + PromptSourceInterrupt PromptSourceID = "turn:interrupt" +) + +type PromptCachePolicy string + +const ( + PromptCacheDefault PromptCachePolicy = "" + PromptCacheEphemeral PromptCachePolicy = "ephemeral" + PromptCacheNone PromptCachePolicy = "none" +) + +type PromptPlacement struct { + Layer PromptLayer + Slot PromptSlot +} + +type PromptSourceDescriptor struct { + ID PromptSourceID + Owner string + Description string + Allowed []PromptPlacement + StableByDefault bool +} + +type PromptSource struct { + ID PromptSourceID + Name string + Path string +} + +type PromptPart struct { + ID string + Layer PromptLayer + Slot PromptSlot + Source PromptSource + Title string + Content string + Stable bool + Cache PromptCachePolicy +} + +type PromptBuildRequest struct { + History []providers.Message + Summary string + + CurrentMessage string + Media []string + + Channel string + ChatID string + SenderID string + SenderDisplayName string + + ActiveSkills []string + Overlays []PromptPart +} + +type PromptContributor interface { + PromptSource() PromptSourceDescriptor + ContributePrompt(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) +} + +type PromptRegistry struct { + mu sync.RWMutex + sources map[PromptSourceID]PromptSourceDescriptor + contributors []PromptContributor + warned map[PromptSourceID]struct{} +} + +func NewPromptRegistry() *PromptRegistry { + r := &PromptRegistry{ + sources: make(map[PromptSourceID]PromptSourceDescriptor), + warned: make(map[PromptSourceID]struct{}), + } + for _, desc := range builtinPromptSources() { + if err := r.RegisterSource(desc); err != nil { + logger.WarnCF("agent", "Failed to register builtin prompt source", map[string]any{ + "source": desc.ID, + "error": err.Error(), + }) + } + } + return r +} + +func builtinPromptSources() []PromptSourceDescriptor { + return []PromptSourceDescriptor{ + { + ID: PromptSourceKernel, + Owner: "agent", + Description: "Core picoclaw identity and hard rules", + Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotIdentity}}, + StableByDefault: true, + }, + { + ID: PromptSourceHierarchy, + Owner: "agent", + Description: "Prompt hierarchy rules", + Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotHierarchy}}, + StableByDefault: true, + }, + { + ID: PromptSourceWorkspace, + Owner: "workspace", + Description: "Workspace and agent definition files", + Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}}, + StableByDefault: true, + }, + { + ID: PromptSourceToolDiscovery, + Owner: "tools", + Description: "Tool discovery instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + }, + { + ID: PromptSourceToolRegistry, + Owner: "tools", + Description: "Native provider tool definitions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + }, + { + ID: PromptSourceSkillCatalog, + Owner: "skills", + Description: "Installed skill catalog", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotSkillCatalog}}, + StableByDefault: true, + }, + { + ID: PromptSourceActiveSkills, + Owner: "skills", + Description: "Active skill instructions for the current request", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}}, + StableByDefault: false, + }, + { + ID: PromptSourceMemory, + Owner: "memory", + Description: "Workspace memory context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotMemory}}, + StableByDefault: true, + }, + { + ID: PromptSourceRuntime, + Owner: "agent", + Description: "Per-request runtime context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotRuntime}}, + StableByDefault: false, + }, + { + ID: PromptSourceSummary, + Owner: "context_manager", + Description: "Conversation summary context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotSummary}}, + StableByDefault: false, + }, + { + ID: PromptSourceOutputPolicy, + Owner: "agent", + Description: "Output formatting policy", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotOutput}}, + StableByDefault: true, + }, + { + ID: PromptSourceSubTurnProfile, + Owner: "subturn", + Description: "Child agent profile instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}}, + StableByDefault: false, + }, + { + ID: PromptSourceUserMessage, + Owner: "turn", + Description: "Current user message for this turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotMessage}}, + StableByDefault: false, + }, + { + ID: PromptSourceSteering, + Owner: "turn", + Description: "Steering message injected into a running turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSteering}}, + StableByDefault: false, + }, + { + ID: PromptSourceSubTurnResult, + Owner: "turn", + Description: "SubTurn result injected into a parent turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSubTurn}}, + StableByDefault: false, + }, + { + ID: PromptSourceInterrupt, + Owner: "turn", + Description: "Graceful interrupt hint injected into the terminal LLM call", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotInterrupt}}, + StableByDefault: false, + }, + } +} + +func (r *PromptRegistry) RegisterSource(desc PromptSourceDescriptor) error { + if r == nil { + return fmt.Errorf("prompt registry is nil") + } + desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID))) + if desc.ID == "" { + return fmt.Errorf("prompt source id is required") + } + if len(desc.Allowed) == 0 { + return fmt.Errorf("prompt source %q must declare at least one placement", desc.ID) + } + + r.mu.Lock() + defer r.mu.Unlock() + r.sources[desc.ID] = clonePromptSourceDescriptor(desc) + return nil +} + +func (r *PromptRegistry) RegisterContributor(contributor PromptContributor) error { + if r == nil { + return fmt.Errorf("prompt registry is nil") + } + if contributor == nil { + return fmt.Errorf("prompt contributor is nil") + } + desc := contributor.PromptSource() + desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID))) + if err := r.RegisterSource(desc); err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + r.contributors = slices.DeleteFunc(r.contributors, func(existing PromptContributor) bool { + return PromptSourceID(strings.TrimSpace(string(existing.PromptSource().ID))) == desc.ID + }) + r.contributors = append(r.contributors, contributor) + return nil +} + +func (r *PromptRegistry) Collect(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) { + if r == nil { + return nil, nil + } + + r.mu.RLock() + contributors := append([]PromptContributor(nil), r.contributors...) + r.mu.RUnlock() + + var parts []PromptPart + for _, contributor := range contributors { + contributed, err := contributor.ContributePrompt(ctx, req) + if err != nil { + return nil, err + } + for _, part := range contributed { + if err := r.ValidatePart(part); err != nil { + return nil, err + } + parts = append(parts, part) + } + } + return parts, nil +} + +func (r *PromptRegistry) ValidatePart(part PromptPart) error { + if r == nil { + return nil + } + sourceID := PromptSourceID(strings.TrimSpace(string(part.Source.ID))) + if sourceID == "" { + return fmt.Errorf("prompt part %q has empty source id", part.ID) + } + + r.mu.Lock() + defer r.mu.Unlock() + + desc, ok := r.sources[sourceID] + if !ok { + if _, warned := r.warned[sourceID]; !warned { + r.warned[sourceID] = struct{}{} + logger.WarnCF("agent", "Unregistered prompt source allowed in compatibility mode", map[string]any{ + "source": sourceID, + "layer": part.Layer, + "slot": part.Slot, + "part": part.ID, + }) + } + return nil + } + if promptPlacementAllowed(desc.Allowed, PromptPlacement{Layer: part.Layer, Slot: part.Slot}) { + return nil + } + return fmt.Errorf("prompt source %q cannot write to %s/%s", sourceID, part.Layer, part.Slot) +} + +func promptPlacementAllowed(allowed []PromptPlacement, placement PromptPlacement) bool { + return slices.ContainsFunc(allowed, func(candidate PromptPlacement) bool { + return candidate.Layer == placement.Layer && candidate.Slot == placement.Slot + }) +} + +func clonePromptSourceDescriptor(desc PromptSourceDescriptor) PromptSourceDescriptor { + desc.Allowed = append([]PromptPlacement(nil), desc.Allowed...) + return desc +} + +type PromptStack struct { + registry *PromptRegistry + parts []PromptPart + sealed bool +} + +func NewPromptStack(registry *PromptRegistry) *PromptStack { + return &PromptStack{registry: registry} +} + +func (s *PromptStack) Add(part PromptPart) error { + if s == nil { + return fmt.Errorf("prompt stack is nil") + } + if s.sealed { + return fmt.Errorf("prompt stack is sealed") + } + if strings.TrimSpace(part.Content) == "" { + return nil + } + if strings.TrimSpace(part.ID) == "" { + return fmt.Errorf("prompt part id is required") + } + if s.registry != nil { + if err := s.registry.ValidatePart(part); err != nil { + return err + } + } + s.parts = append(s.parts, part) + return nil +} + +func (s *PromptStack) Seal() { + if s != nil { + s.sealed = true + } +} + +func (s *PromptStack) Parts() []PromptPart { + if s == nil || len(s.parts) == 0 { + return nil + } + return append([]PromptPart(nil), s.parts...) +} + +func renderPromptPartsLegacy(parts []PromptPart) string { + textParts := make([]string, 0, len(parts)) + for _, part := range sortPromptParts(parts) { + if strings.TrimSpace(part.Content) == "" { + continue + } + textParts = append(textParts, part.Content) + } + return strings.Join(textParts, "\n\n---\n\n") +} + +func sortPromptParts(parts []PromptPart) []PromptPart { + sorted := append([]PromptPart(nil), parts...) + slices.SortStableFunc(sorted, func(a, b PromptPart) int { + if d := layerPriority(b.Layer) - layerPriority(a.Layer); d != 0 { + return d + } + if d := slotPriority(b.Slot) - slotPriority(a.Slot); d != 0 { + return d + } + if a.Source.ID != b.Source.ID { + return strings.Compare(string(a.Source.ID), string(b.Source.ID)) + } + return strings.Compare(a.ID, b.ID) + }) + return sorted +} + +func layerPriority(layer PromptLayer) int { + switch layer { + case PromptLayerKernel: + return 100 + case PromptLayerInstruction: + return 80 + case PromptLayerCapability: + return 60 + case PromptLayerContext: + return 40 + case PromptLayerTurn: + return 20 + default: + return 0 + } +} + +func slotPriority(slot PromptSlot) int { + switch slot { + case PromptSlotIdentity: + return 1000 + case PromptSlotHierarchy: + return 990 + case PromptSlotWorkspace: + return 900 + case PromptSlotTooling: + return 800 + case PromptSlotMCP: + return 790 + case PromptSlotSkillCatalog: + return 780 + case PromptSlotActiveSkill: + return 770 + case PromptSlotMemory: + return 700 + case PromptSlotOutput: + return 695 + case PromptSlotRuntime: + return 690 + case PromptSlotSummary: + return 680 + case PromptSlotMessage: + return 600 + case PromptSlotSteering: + return 590 + case PromptSlotSubTurn: + return 580 + case PromptSlotInterrupt: + return 570 + default: + return 0 + } +} diff --git a/pkg/agent/prompt_contributors.go b/pkg/agent/prompt_contributors.go new file mode 100644 index 000000000..960572e03 --- /dev/null +++ b/pkg/agent/prompt_contributors.go @@ -0,0 +1,139 @@ +package agent + +import ( + "context" + "fmt" + "strings" +) + +type toolDiscoveryPromptContributor struct { + useBM25 bool + useRegex bool +} + +func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { + return PromptSourceDescriptor{ + ID: PromptSourceToolDiscovery, + Owner: "tools", + Description: "Tool discovery instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + } +} + +func (c toolDiscoveryPromptContributor) ContributePrompt( + _ context.Context, + _ PromptBuildRequest, +) ([]PromptPart, error) { + content := formatToolDiscoveryRule(c.useBM25, c.useRegex) + if strings.TrimSpace(content) == "" { + return nil, nil + } + + return []PromptPart{ + { + ID: "capability.tool_discovery", + Layer: PromptLayerCapability, + Slot: PromptSlotTooling, + Source: PromptSource{ID: PromptSourceToolDiscovery, Name: "tool_registry:discovery"}, + Title: "tool discovery", + Content: content, + Stable: true, + Cache: PromptCacheEphemeral, + }, + }, nil +} + +type mcpServerPromptContributor struct { + serverName string + toolCount int + deferred bool +} + +func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor { + return PromptSourceDescriptor{ + ID: mcpPromptSourceID(c.serverName), + Owner: "mcp", + Description: fmt.Sprintf("MCP server %q capability prompt", c.serverName), + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}}, + StableByDefault: true, + } +} + +func (c mcpServerPromptContributor) ContributePrompt( + _ context.Context, + _ PromptBuildRequest, +) ([]PromptPart, error) { + serverName := strings.TrimSpace(c.serverName) + if serverName == "" || c.toolCount <= 0 { + return nil, nil + } + + availability := "available as native tools" + if c.deferred { + availability = "hidden behind tool discovery until unlocked" + } + + return []PromptPart{ + { + ID: "capability.mcp." + promptSourceComponent(serverName), + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: mcpPromptSourceID(serverName), Name: "mcp:" + serverName}, + Title: "MCP server capability", + Content: fmt.Sprintf( + "MCP server `%s` is connected. It contributes %d tool(s), currently %s.", + serverName, + c.toolCount, + availability, + ), + Stable: true, + Cache: PromptCacheEphemeral, + }, + }, nil +} + +func mcpPromptSourceID(serverName string) PromptSourceID { + return PromptSourceID("mcp:" + promptSourceComponent(serverName)) +} + +func promptSourceComponent(value string) string { + const maxLen = 64 + + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "unnamed" + } + + var b strings.Builder + lastWasSep := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + lastWasSep = false + case r >= '0' && r <= '9': + b.WriteRune(r) + lastWasSep = false + case r == '-' || r == '_': + if !lastWasSep && b.Len() > 0 { + b.WriteRune(r) + lastWasSep = true + } + default: + if !lastWasSep && b.Len() > 0 { + b.WriteRune('_') + lastWasSep = true + } + } + } + + result := strings.Trim(b.String(), "_") + if result == "" { + return "unnamed" + } + if len(result) > maxLen { + return result[:maxLen] + } + return result +} diff --git a/pkg/agent/prompt_test.go b/pkg/agent/prompt_test.go new file mode 100644 index 000000000..b76b0040d --- /dev/null +++ b/pkg/agent/prompt_test.go @@ -0,0 +1,275 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestPromptRegistry_RejectsRegisteredSourceWrongPlacement(t *testing.T) { + registry := NewPromptRegistry() + if err := registry.RegisterSource(PromptSourceDescriptor{ + ID: "test:source", + Owner: "test", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + }); err != nil { + t.Fatalf("RegisterSource() error = %v", err) + } + + err := registry.ValidatePart(PromptPart{ + ID: "wrong.placement", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: "test:source"}, + Content: "runtime text", + }) + if err == nil { + t.Fatal("ValidatePart() error = nil, want placement error") + } +} + +func TestPromptRegistry_AllowsUnregisteredSourceInCompatibilityMode(t *testing.T) { + registry := NewPromptRegistry() + + err := registry.ValidatePart(PromptPart{ + ID: "unregistered.part", + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: "mcp:dynamic-server"}, + Content: "dynamic MCP prompt", + }) + if err != nil { + t.Fatalf("ValidatePart() error = %v, want nil for unregistered source", err) + } +} + +func TestRenderPromptPartsLegacy_UsesLayerAndSlotOrder(t *testing.T) { + parts := []PromptPart{ + { + ID: "context.runtime", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: PromptSourceRuntime}, + Content: "runtime", + }, + { + ID: "kernel.identity", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel}, + Content: "kernel", + }, + { + ID: "capability.skill", + Layer: PromptLayerCapability, + Slot: PromptSlotActiveSkill, + Source: PromptSource{ID: "skill:test"}, + Content: "skill", + }, + { + ID: "instruction.workspace", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceWorkspace}, + Content: "workspace", + }, + } + + got := renderPromptPartsLegacy(parts) + want := strings.Join([]string{"kernel", "workspace", "skill", "runtime"}, "\n\n---\n\n") + if got != want { + t.Fatalf("renderPromptPartsLegacy() = %q, want %q", got, want) + } +} + +func TestBuildMessagesFromPrompt_IncludesSystemPromptOverlay(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "do child task", + Overlays: promptOverlaysForOptions(processOptions{ + SystemPromptOverride: "Use child-only system instructions.", + }), + }) + + if len(messages) < 2 { + t.Fatalf("messages len = %d, want at least 2", len(messages)) + } + if messages[0].Role != "system" { + t.Fatalf("messages[0].Role = %q, want system", messages[0].Role) + } + if !strings.Contains(messages[0].Content, "Use child-only system instructions.") { + t.Fatalf("system prompt missing overlay: %q", messages[0].Content) + } + if messages[1].Role != "user" || messages[1].Content != "do child task" { + t.Fatalf("messages[1] = %#v, want user task", messages[1]) + } +} + +func TestBuildMessagesFromPrompt_AttachesInternalPromptMetadata(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + Summary: "prior context", + }) + if len(messages) != 2 { + t.Fatalf("messages len = %d, want 2", len(messages)) + } + + system := messages[0] + if len(system.SystemParts) < 3 { + t.Fatalf("system parts len = %d, want at least 3", len(system.SystemParts)) + } + if system.SystemParts[0].PromptLayer != string(PromptLayerKernel) || + system.SystemParts[0].PromptSlot != string(PromptSlotIdentity) || + system.SystemParts[0].PromptSource != string(PromptSourceKernel) { + t.Fatalf("static system metadata = %#v, want kernel identity", system.SystemParts[0]) + } + + var hasRuntime, hasSummary bool + for _, part := range system.SystemParts { + switch part.PromptSource { + case string(PromptSourceRuntime): + hasRuntime = true + if part.CacheControl != nil { + t.Fatalf("runtime cache control = %#v, want nil", part.CacheControl) + } + case string(PromptSourceSummary): + hasSummary = true + if part.CacheControl != nil { + t.Fatalf("summary cache control = %#v, want nil", part.CacheControl) + } + } + } + if !hasRuntime { + t.Fatal("system parts missing runtime prompt metadata") + } + if !hasSummary { + t.Fatal("system parts missing summary prompt metadata") + } + + user := messages[1] + if user.PromptLayer != string(PromptLayerTurn) || + user.PromptSlot != string(PromptSlotMessage) || + user.PromptSource != string(PromptSourceUserMessage) { + t.Fatalf("user message metadata = %#v, want turn message", user) + } + + data, err := json.Marshal(messages) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if strings.Contains(string(data), "PromptSource") || + strings.Contains(string(data), "PromptLayer") || + strings.Contains(string(data), "PromptSlot") { + t.Fatalf("internal prompt metadata leaked into JSON: %s", data) + } +} + +func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + system := messages[0] + if !strings.Contains(system.Content, "tool_search_tool_bm25") { + t.Fatalf("system prompt missing tool discovery rule: %q", system.Content) + } + + var found bool + for _, part := range system.SystemParts { + if part.PromptSource == string(PromptSourceToolDiscovery) { + found = true + if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotTooling) { + t.Fatalf("tool discovery metadata = %#v, want capability/tooling", part) + } + if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" { + t.Fatalf("tool discovery cache control = %#v, want ephemeral", part.CacheControl) + } + } + } + if !found { + t.Fatal("system parts missing tool discovery prompt metadata") + } +} + +func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + err := cb.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: "GitHub Server", + toolCount: 3, + deferred: true, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + system := messages[0] + if !strings.Contains(system.Content, "MCP server `GitHub Server` is connected") { + t.Fatalf("system prompt missing MCP contributor content: %q", system.Content) + } + + var found bool + for _, part := range system.SystemParts { + if part.PromptSource == "mcp:github_server" { + found = true + if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotMCP) { + t.Fatalf("mcp metadata = %#v, want capability/mcp", part) + } + if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" { + t.Fatalf("mcp cache control = %#v, want ephemeral", part.CacheControl) + } + } + } + if !found { + t.Fatal("system parts missing MCP prompt metadata") + } +} + +type testPromptContributor struct { + desc PromptSourceDescriptor + part PromptPart +} + +func (c testPromptContributor) PromptSource() PromptSourceDescriptor { + return c.desc +} + +func (c testPromptContributor) ContributePrompt(_ context.Context, _ PromptBuildRequest) ([]PromptPart, error) { + return []PromptPart{c.part}, nil +} + +func TestContextBuilder_CollectsRegisteredPromptContributors(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + sourceID := PromptSourceID("test:contributor") + err := cb.RegisterPromptContributor(testPromptContributor{ + desc: PromptSourceDescriptor{ + ID: sourceID, + Owner: "test", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}}, + }, + part: PromptPart{ + ID: "capability.mcp.test", + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: sourceID, Name: "test"}, + Content: "registered contributor prompt", + }, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + if !strings.Contains(messages[0].Content, "registered contributor prompt") { + t.Fatalf("system prompt missing contributor content: %q", messages[0].Content) + } +} diff --git a/pkg/agent/prompt_turn.go b/pkg/agent/prompt_turn.go new file mode 100644 index 000000000..588a8f00f --- /dev/null +++ b/pkg/agent/prompt_turn.go @@ -0,0 +1,129 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func promptBuildRequestForTurn( + ts *turnState, + history []providers.Message, + summary string, + currentMessage string, + media []string, +) PromptBuildRequest { + return PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: currentMessage, + Media: append([]string(nil), media...), + Channel: ts.channel, + ChatID: ts.chatID, + SenderID: ts.opts.Dispatch.SenderID(), + SenderDisplayName: ts.opts.SenderDisplayName, + ActiveSkills: activeSkillNames(ts.agent, ts.opts), + Overlays: promptOverlaysForOptions(ts.opts), + } +} + +func promptOverlaysForOptions(opts processOptions) []PromptPart { + systemPrompt := strings.TrimSpace(opts.SystemPromptOverride) + if systemPrompt == "" { + return nil + } + + return []PromptPart{ + { + ID: "instruction.subturn_profile", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceSubTurnProfile, Name: "subturn.profile"}, + Title: "SubTurn System Instructions", + Content: systemPrompt, + Stable: false, + Cache: PromptCacheNone, + }, + } +} + +func promptContentBlock(part PromptPart, cache *providers.CacheControl) providers.ContentBlock { + if cache == nil { + cache = cacheControlForPromptPart(part) + } + return providers.ContentBlock{ + Type: "text", + Text: part.Content, + CacheControl: cache, + PromptLayer: string(part.Layer), + PromptSlot: string(part.Slot), + PromptSource: string(part.Source.ID), + } +} + +func cacheControlForPromptPart(part PromptPart) *providers.CacheControl { + switch part.Cache { + case PromptCacheEphemeral: + return &providers.CacheControl{Type: "ephemeral"} + default: + return nil + } +} + +func promptMessageWithMetadata( + msg providers.Message, + layer PromptLayer, + slot PromptSlot, + source PromptSourceID, +) providers.Message { + msg.PromptLayer = string(layer) + msg.PromptSlot = string(slot) + msg.PromptSource = string(source) + return msg +} + +func promptMessageWithDefaultMetadata( + msg providers.Message, + layer PromptLayer, + slot PromptSlot, + source PromptSourceID, +) providers.Message { + if strings.TrimSpace(msg.PromptSource) != "" { + return msg + } + return promptMessageWithMetadata(msg, layer, slot, source) +} + +func userPromptMessage(content string, media []string) providers.Message { + msg := providers.Message{ + Role: "user", + Content: content, + } + if len(media) > 0 { + msg.Media = append([]string(nil), media...) + } + return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage) +} + +func steeringPromptMessage(msg providers.Message) providers.Message { + return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering) +} + +func subTurnResultPromptMessage(content string) providers.Message { + return promptMessageWithMetadata( + providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}, + PromptLayerTurn, + PromptSlotSubTurn, + PromptSourceSubTurnResult, + ) +} + +func interruptPromptMessage(content string) providers.Message { + return promptMessageWithMetadata( + providers.Message{Role: "user", Content: content}, + PromptLayerTurn, + PromptSlotInterrupt, + PromptSourceInterrupt, + ) +} diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index a2e5fec21..2efa7bbf4 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers return fmt.Errorf("steering queue is not initialized") } + msg = steeringPromptMessage(msg) if err := al.steering.pushScope(scope, msg); err != nil { logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{ "error": err.Error(), @@ -348,29 +349,46 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { // // If no steering messages are pending, it returns an empty string. func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) { - if active := al.GetActiveTurn(); active != nil { - return "", fmt.Errorf("turn %s is still active", active.TurnID) + // Claim the session with a unique placeholder to prevent a TOCTOU race where two + // concurrent Continue calls for the same session both pass the active-turn + // check and create parallel turns. The placeholder is replaced by the real + // turnState inside continueWithSteeringMessages → runAgentLoop → registerActiveTurn. + placeholder := &turnState{ + turnID: "pending-continue-" + sessionKey + "-" + fmt.Sprintf("%d", al.turnSeq.Add(1)), + phase: TurnPhaseSetup, } + if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + if active := al.GetActiveTurnBySession(sessionKey); active != nil { + return "", fmt.Errorf("turn %s is still active for session %q", active.TurnID, sessionKey) + } + // Another Continue just claimed the slot; let it handle the steering. + return "", nil + } + if err := al.ensureHooksInitialized(ctx); err != nil { + al.activeTurnStates.Delete(sessionKey) return "", err } if err := al.ensureMCPInitialized(ctx); err != nil { + al.activeTurnStates.Delete(sessionKey) return "", err } steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey) if len(steeringMsgs) == 0 { + al.activeTurnStates.Delete(sessionKey) return "", nil } agent := al.agentForSession(sessionKey) if agent == nil { + al.activeTurnStates.Delete(sessionKey) return "", fmt.Errorf("no agent available for session %q", sessionKey) } if tool, ok := agent.Tools.Get("message"); ok { - if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { - resetter.ResetSentInRound() + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) } } @@ -403,11 +421,18 @@ func (al *AgentLoop) InterruptGraceful(hint string) error { return nil } +// InterruptHard aborts an arbitrary active turn. In parallel mode this may +// target the wrong session. Prefer HardAbort(sessionKey) instead. +// +// Deprecated: Use HardAbort(sessionKey) for session-safe aborts. func (al *AgentLoop) InterruptHard() error { ts := al.getAnyActiveTurnState() if ts == nil { return fmt.Errorf("no active turn") } + if strings.HasPrefix(ts.turnID, "pending-") { + return fmt.Errorf("turn is still initializing for session %s", ts.sessionKey) + } if !ts.requestHardAbort() { return fmt.Errorf("turn %s is already aborting", ts.turnID) } @@ -474,6 +499,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error { return fmt.Errorf("invalid turn state type for session %s", sessionKey) } + if strings.HasPrefix(ts.turnID, "pending-") { + return fmt.Errorf("turn is still initializing for session %s", sessionKey) + } + logger.InfoCF("agent", "Hard abort triggered", map[string]any{ "session_key": sessionKey, "turn_id": ts.turnID, diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 8e6063f08..bba988672 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -341,95 +341,6 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) { } } -func TestDrainBusToSteering_RequeuesDifferentScopeMessage(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, - }, - }, - Session: config.SessionConfig{ - Dimensions: []string{"sender"}, - }, - } - - msgBus := bus.NewMessageBus() - al := NewAgentLoop(cfg, msgBus, &mockProvider{}) - - activeMsg := bus.InboundMessage{ - Context: bus.InboundContext{ - Channel: "telegram", - ChatID: "chat1", - ChatType: "direct", - SenderID: "user1", - }, - Content: "active turn", - } - activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg) - if !ok { - t.Fatal("expected active message to resolve to a steering scope") - } - - otherMsg := bus.InboundMessage{ - Context: bus.InboundContext{ - Channel: "telegram", - ChatID: "chat2", - ChatType: "direct", - SenderID: "user2", - }, - Content: "other session", - } - otherScope, _, ok := al.resolveSteeringTarget(otherMsg) - if !ok { - t.Fatal("expected other message to resolve to a steering scope") - } - if otherScope == activeScope { - t.Fatalf("expected different steering scopes, got same scope %q", activeScope) - } - - if err := msgBus.PublishInbound(context.Background(), otherMsg); err != nil { - t.Fatalf("PublishInbound failed: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - done := make(chan struct{}) - go func() { - al.drainBusToSteering(ctx, activeScope, activeAgentID) - close(done) - }() - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("timeout waiting for drainBusToSteering to stop") - } - - if msgs := al.dequeueSteeringMessagesForScope(activeScope); len(msgs) != 0 { - t.Fatalf("expected no steering messages for active scope, got %v", msgs) - } - - select { - case <-ctx.Done(): - t.Fatalf("timeout waiting for requeued message on inbound bus") - case requeued := <-msgBus.InboundChan(): - if requeued.Context.Channel != otherMsg.Context.Channel || requeued.Context.ChatID != otherMsg.Context.ChatID || - requeued.Content != otherMsg.Content { - t.Fatalf("requeued message mismatch: got %+v want %+v", requeued, otherMsg) - } - } -} - // slowTool simulates a tool that takes some time to execute. type slowTool struct { name string diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index cd193017b..4d824bd3a 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -10,6 +10,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -462,7 +463,8 @@ func spawnSubTurn( }() // 8. Execute sub-turn via the real agent loop. - turnRes, turnErr := al.runTurn(childCtx, childTS) + pipeline := NewPipeline(al) + turnRes, turnErr := al.runTurn(childCtx, childTS, pipeline) // Release the concurrency semaphore immediately after runTurn completes, // before the cleanup defer runs. This prevents a deadlock where: @@ -622,6 +624,10 @@ func (e *ephemeralSessionStore) AddMessage(_, role, content string) { } func (e *ephemeralSessionStore) AddFullMessage(_ string, msg providers.Message) { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return + } + e.mu.Lock() defer e.mu.Unlock() e.history = append(e.history, msg) @@ -651,6 +657,7 @@ func (e *ephemeralSessionStore) SetSummary(_, summary string) { func (e *ephemeralSessionStore) SetHistory(_ string, history []providers.Message) { e.mu.Lock() defer e.mu.Unlock() + history = messageutil.FilterInvalidHistoryMessages(history) e.history = make([]providers.Message, len(history)) copy(e.history, history) e.truncateLocked() diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 6a2ba835d..040063249 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -1650,6 +1650,38 @@ func TestGrandchildAbort_CascadingCancellation(t *testing.T) { } } +func TestNestedSubTurn_GracefulFinishSignalsDirectChildren(t *testing.T) { + parentCtx := context.Background() + parentTS := &turnState{ + ctx: parentCtx, + turnID: "parent-graceful", + depth: 1, + pendingResults: make(chan *tools.ToolResult, 16), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(parentCtx) + + childTS := &turnState{ + ctx: context.Background(), + turnID: "child-graceful", + depth: 2, + parentTurnState: parentTS, + pendingResults: make(chan *tools.ToolResult, 16), + } + + if childTS.IsParentEnded() { + t.Fatal("IsParentEnded should be false before parent finishes") + } + + parentTS.Finish(false) + + if !parentTS.parentEnded.Load() { + t.Fatal("parentEnded should be true after graceful finish") + } + if !childTS.IsParentEnded() { + t.Fatal("nested child should observe parent graceful finish") + } +} + // TestSpawnDuringAbort_RaceCondition verifies behavior when trying to spawn // a sub-turn while the parent is being aborted. func TestSpawnDuringAbort_RaceCondition(t *testing.T) { diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go new file mode 100644 index 000000000..ade2b7c21 --- /dev/null +++ b/pkg/agent/turn_coord.go @@ -0,0 +1,624 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipeline) (turnResult, error) { + turnCtx, turnCancel := context.WithCancel(ctx) + defer turnCancel() + ts.setTurnCancel(turnCancel) + + // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. + turnCtx = withTurnState(turnCtx, ts) + turnCtx = WithAgentLoop(turnCtx, al) + + al.registerActiveTurn(ts) + defer al.clearActiveTurn(ts) + + turnStatus := TurnEndStatusCompleted + defer func() { + al.emitEvent( + EventKindTurnEnd, + ts.eventMeta("runTurn", "turn.end"), + TurnEndPayload{ + Status: turnStatus, + Iterations: ts.currentIteration(), + Duration: time.Since(ts.startedAt), + FinalContentLen: ts.finalContentLen(), + }, + ) + }() + + al.emitEvent( + EventKindTurnStart, + ts.eventMeta("runTurn", "turn.start"), + TurnStartPayload{ + UserMessage: ts.userMessage, + MediaCount: len(ts.media), + }, + ) + + // SetupTurn extracts the one-time initialization phase. + exec, err := pipeline.SetupTurn(turnCtx, ts) + if err != nil { + return turnResult{}, err + } + + // Convenience references to exec fields used throughout the turn loop. + messages := exec.messages + pendingMessages := exec.pendingMessages + maxMediaSize := pipeline.Cfg.Agents.Defaults.GetMaxMediaSize() + finalContent := exec.finalContent + + for ts.currentIteration() < ts.agent.MaxIterations || len(exec.pendingMessages) > 0 || func() bool { + graceful, _ := ts.gracefulInterruptRequested() + return graceful + }() { + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + iteration := ts.currentIteration() + 1 + ts.setIteration(iteration) + ts.setPhase(TurnPhaseRunning) + + if iteration > 1 { + // For subsequent iterations, read from exec.pendingMessages which + // is where ExecuteTools (or initial poll) deposits steering. + // We do NOT call dequeueSteeringMessagesForScope here because + // steering was already consumed from al.steering by ExecuteTools. + if len(exec.pendingMessages) > 0 { + pendingMessages = append(pendingMessages, exec.pendingMessages...) + exec.pendingMessages = nil + } + } else if !ts.opts.SkipInitialSteeringPoll { + if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { + pendingMessages = append(pendingMessages, steerMsgs...) + } + } + + // Check if parent turn has ended (SubTurn support from HEAD) + if ts.parentTurnState != nil && ts.IsParentEnded() { + if !ts.critical { + logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + break + } + logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + } + + // Poll for pending SubTurn results (from HEAD) + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := subTurnResultPromptMessage(content) + pendingMessages = append(pendingMessages, msg) + } + default: + // No results available + } + } + + // Inject pending steering messages + if len(pendingMessages) > 0 { + resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) + totalContentLen := 0 + for i, pm := range pendingMessages { + messages = append(messages, resolvedPending[i]) + totalContentLen += len(pm.Content) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) + ts.recordPersistedMessage(pm) + ts.ingestMessage(turnCtx, al, pm) + } + logger.InfoCF("agent", "Injected steering message into context", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_len": len(pm.Content), + "media_count": len(pm.Media), + }) + } + al.emitEvent( + EventKindSteeringInjected, + ts.eventMeta("runTurn", "turn.steering.injected"), + SteeringInjectedPayload{ + Count: len(pendingMessages), + TotalContentLen: totalContentLen, + }, + ) + // Clear exec.pendingMessages after injection so InitialSteeringMessages + // are not re-injected on subsequent iterations (Issue 2 fix). + exec.pendingMessages = nil + } + // Always sync messages into exec.messages so CallLLM sees the updated state + exec.messages = messages + + logger.DebugCF("agent", "LLM iteration", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "max": ts.agent.MaxIterations, + }) + + // Execute LLM call via Pipeline + ts.setPhase(TurnPhaseRunning) + ctrl, callErr := pipeline.CallLLM(ctx, turnCtx, ts, exec, iteration) + if callErr != nil { + turnStatus = TurnEndStatusError + return turnResult{}, callErr + } + messages = exec.messages + pendingMessages = exec.pendingMessages + finalContent = exec.finalContent + + switch ctrl { + case ControlContinue: + continue + case ControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // Ensure empty response falls back to DefaultResponse + if finalContent == "" { + finalContent = ts.opts.DefaultResponse + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + case ControlToolLoop: + // Execute tools via Pipeline + toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration) + switch toolCtrl { + case ToolControlContinue: + // Re-read exec.messages since ExecuteTools may have updated it + // (added tool results/skipped messages) before returning ControlContinue + messages = exec.messages + continue + case ToolControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // ExecuteTools returned ControlBreak: + // - allResponsesHandled=true: finalize without DefaultResponse (exec.finalContent empty) + // - allResponsesHandled=false: coordinator applies DefaultResponse before finalize + if exec.allResponsesHandled { + finalContent = "" + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + } + } + } + + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + if finalContent == "" { + if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { + finalContent = toolLimitResponse + } else { + finalContent = ts.opts.DefaultResponse + } + } + + // Check hard abort before finalizing (may have been set during tool execution) + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) +} + +func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { + ts.setPhase(TurnPhaseAborted) + if !ts.opts.NoHistory { + if err := ts.restoreSession(ts.agent); err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("abortTurn", "turn.error"), + ErrorPayload{ + Stage: "session_restore", + Message: err.Error(), + }, + ) + return turnResult{}, err + } + } + return turnResult{status: TurnEndStatusAborted}, nil +} + +func (al *AgentLoop) selectCandidates( + agent *AgentInstance, + userMsg string, + history []providers.Message, +) (candidates []providers.FallbackCandidate, model string, usedLight bool) { + if agent.Router == nil || len(agent.LightCandidates) == 0 { + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) + if !usedLight { + logger.DebugCF("agent", "Model routing: primary model selected", + map[string]any{ + "agent_id": agent.ID, + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + logger.InfoCF("agent", "Model routing: light model selected", + map[string]any{ + "agent_id": agent.ID, + "light_model": agent.Router.LightModel(), + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true +} + +func (al *AgentLoop) resolveContextManager() ContextManager { + name := al.cfg.Agents.Defaults.ContextManager + if name == "" || name == "legacy" { + return &legacyContextManager{al: al} + } + factory, ok := lookupContextManager(name) + if !ok { + logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ + "name": name, + }) + return &legacyContextManager{al: al} + } + cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) + if err != nil { + logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ + "name": name, + "error": err.Error(), + }) + return &legacyContextManager{al: al} + } + return cm +} + +func (al *AgentLoop) askSideQuestion( + ctx context.Context, + agent *AgentInstance, + opts *processOptions, + question string, +) (string, error) { + if agent == nil { + return "", fmt.Errorf("askSideQuestion: no agent available for /btw") + } + + question = strings.TrimSpace(question) + if question == "" { + return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw ")) + } + + if opts != nil { + normalizeProcessOptionsInPlace(opts) + } + + var media []string + var channel, chatID, senderID, senderDisplayName string + if opts != nil { + media = opts.Media + channel = opts.Channel + chatID = opts.ChatID + senderID = opts.SenderID + senderDisplayName = opts.SenderDisplayName + } + + // Build messages with context but WITHOUT adding to session history + var history []providers.Message + var summary string + if opts != nil && !opts.NoHistory { + if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: opts.SessionKey, + Budget: agent.ContextWindow, + MaxTokens: agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + + messages := agent.ContextBuilder.BuildMessages( + history, + summary, + question, + media, + channel, + chatID, + senderID, + senderDisplayName, + ) + + maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + + activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages) + selectedModelName := sideQuestionModelName(agent, usedLight) + + llmOpts := map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID + ":btw", + } + + hookModelChanged := false + callProvider := func( + ctx context.Context, + candidate providers.FallbackCandidate, + model string, + forceModel bool, + callMessages []providers.Message, + ) (*providers.LLMResponse, error) { + provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) + if err != nil { + return nil, err + } + defer cleanup() + if !forceModel || strings.TrimSpace(model) == "" { + model = providerModel + } + callOpts := llmOpts + if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { + if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + callOpts = shallowCloneLLMOptions(llmOpts) + callOpts["thinking_level"] = string(agent.ThinkingLevel) + } + } + return provider.Chat(ctx, callMessages, nil, model, callOpts) + } + + turnCtx := newTurnContext(nil, nil, nil) + if opts != nil { + turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) + } + llmModel := activeModel + if al.hooks != nil { + llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.request", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Messages: messages, + Tools: nil, + Options: llmOpts, + GracefulTerminal: false, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel { + hookModelChanged = true + } + llmModel = llmReq.Model + messages = llmReq.Messages + llmOpts = llmReq.Options + } + case HookActionAbortTurn: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + case HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + } + } + if hookModelChanged { + // Hook-selected models must not continue through the pre-hook fallback + // candidate list, otherwise fallback execution would call the original + // candidate model and silently ignore the hook decision. + activeCandidates = nil + } + + callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { + if len(activeCandidates) > 1 && al.fallback != nil { + fbResult, err := al.fallback.Execute( + ctx, + activeCandidates, + func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { + candidate := providers.FallbackCandidate{Provider: providerName, Model: model} + for _, activeCandidate := range activeCandidates { + if activeCandidate.Provider == providerName && activeCandidate.Model == model { + candidate = activeCandidate + break + } + } + return callProvider(ctx, candidate, model, false, callMessages) + }, + ) + if err != nil { + return nil, err + } + return fbResult.Response, nil + } + + var candidate providers.FallbackCandidate + if len(activeCandidates) > 0 { + candidate = activeCandidates[0] + } + return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages) + } + + // Retry without media if vision is unsupported + // Note: Vision retry is only applied to the initial call. If fallback chain + // is used, vision errors from fallback providers will not trigger retry. + var resp *providers.LLMResponse + var err error + resp, err = callSideLLM(messages) + if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { + al.emitEvent( + EventKindLLMRetry, + EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.retry", + turnContext: cloneTurnContext(turnCtx), + }, + LLMRetryPayload{ + Attempt: 1, + MaxRetries: 1, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + messagesWithoutMedia := stripMessageMedia(messages) + resp, err = callSideLLM(messagesWithoutMedia) + } + if err != nil { + return "", err + } + if resp == nil { + return "", nil + } + + // Apply after_llm hooks + if al.hooks != nil { + llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.response", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Response: resp, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + resp = llmResp.Response + } + case HookActionAbortTurn, HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + } + } + + return sideQuestionResponseContent(resp), nil +} + +func (al *AgentLoop) isolatedSideQuestionProvider( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (providers.LLMProvider, string, func(), error) { + if agent == nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") + } + + modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + factory := al.providerFactory + if factory == nil { + factory = providers.CreateProviderFromConfig + } + provider, modelID, err := factory(modelCfg) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + cleanup := func() { + closeProviderIfStateful(provider) + } + return provider, modelID, cleanup, nil +} + +func (al *AgentLoop) sideQuestionModelConfig( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (*config.ModelConfig, error) { + if agent == nil { + return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") + } + + // If candidate has an identity key, use that + if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { + modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) + if err == nil { + return modelCfg, nil + } + // Fallback: create a minimal config if lookup fails + } + + // Otherwise, clean up the base model name and use it + baseModelName = strings.TrimSpace(baseModelName) + modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) + if err != nil { + // Fallback: create a minimal config for test scenarios + model := strings.TrimSpace(baseModelName) + if candidate.Model != "" { + model = candidate.Model + } + if candidate.Provider != "" && candidate.Model != "" { + model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } else { + model = ensureProtocolModel(model) + } + return &config.ModelConfig{ + ModelName: baseModelName, + Model: model, + Workspace: agent.Workspace, + }, nil + } + + // If candidate specifies a different provider/model, override + clone := *modelCfg + if candidate.Provider != "" && candidate.Model != "" { + clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } + return &clone, nil +} diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go new file mode 100644 index 000000000..c059d0a39 --- /dev/null +++ b/pkg/agent/turn_coord_test.go @@ -0,0 +1,615 @@ +package agent + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ============================================================================= +// Mock Providers for turn_coord Tests +// ============================================================================= + +// simpleConvProvider returns a simple text response without tools +type simpleConvProvider struct{} + +func (p *simpleConvProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: "Hello! How can I help you today?", + FinishReason: "stop", + }, nil +} + +func (p *simpleConvProvider) GetDefaultModel() string { + return "simple-model" +} + +type nativeSearchCaptureProvider struct { + lastOpts map[string]any +} + +func (p *nativeSearchCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.lastOpts = make(map[string]any, len(opts)) + for k, v := range opts { + p.lastOpts[k] = v + } + return &providers.LLMResponse{ + Content: "Using native search", + FinishReason: "stop", + }, nil +} + +func (p *nativeSearchCaptureProvider) GetDefaultModel() string { + return "native-search-model" +} + +func (p *nativeSearchCaptureProvider) SupportsNativeSearch() bool { + return true +} + +// toolCallRespProvider returns a tool call response +type toolCallRespProvider struct { + toolName string + toolArgs map[string]any + response string + callCount int + mu sync.Mutex +} + +func (p *toolCallRespProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + count := p.callCount + p.mu.Unlock() + + // First call returns a tool call, subsequent calls return final response + if count == 1 { + return &providers.LLMResponse{ + Content: "Let me search for that information.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: p.toolName, + Arguments: p.toolArgs, + }, + }, + FinishReason: "tool_calls", + }, nil + } + return &providers.LLMResponse{ + Content: p.response, + FinishReason: "stop", + }, nil +} + +func (p *toolCallRespProvider) GetDefaultModel() string { + return "tool-model" +} + +// errorProvider simulates various error conditions +type errorProvider struct { + errType string + callCount int + mu sync.Mutex +} + +func (p *errorProvider) 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() + + switch p.errType { + case "timeout": + return nil, context.DeadlineExceeded + case "context_length": + return nil, errors.New("context_length_exceeded") + case "vision": + return nil, errors.New("vision_unsupported") + default: + return nil, errors.New("unknown error") + } +} + +func (p *errorProvider) GetDefaultModel() string { + return "error-model" +} + +// ============================================================================= +// Test Helper Functions +// ============================================================================= + +func newTurnCoordTestLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, *AgentInstance, func()) { + t.Helper() + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + return al, agent, func() { + al.Close() + } +} + +func makeTestProcessOpts(sessionKey string) processOptions { + return processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "test-chat", + UserMessage: "test message", + DefaultResponse: "I couldn't process your request.", + EnableSummary: false, + SendResponse: false, + NoHistory: false, + } +} + +// ============================================================================= +// Pipeline Method Tests: SetupTurn +// ============================================================================= + +func TestPipeline_SetupTurn_BasicInitialization(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + 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) + } + if exec == nil { + t.Fatal("expected non-nil turnExecution") + } + if len(exec.messages) == 0 { + t.Error("expected messages to be populated") + } + if exec.iteration != 0 { + t.Errorf("expected iteration 0, got %d", exec.iteration) + } +} + +// ============================================================================= +// Pipeline Method Tests: CallLLM +// ============================================================================= + +func TestPipeline_CallLLM_SimpleResponse(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + 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) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Errorf("expected ControlBreak, got %v", ctrl) + } + if exec.response == nil { + t.Fatal("expected non-nil response") + } + if exec.response.Content == "" { + t.Error("expected non-empty content") + } +} + +func TestPipeline_CallLLM_WithToolCall(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "web_search", + toolArgs: map[string]any{"query": "test"}, + response: "Found information about test.", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + 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) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlToolLoop { + t.Errorf("expected ControlToolLoop, got %v", ctrl) + } + if len(exec.normalizedToolCalls) == 0 { + t.Fatal("expected tool calls") + } + if exec.normalizedToolCalls[0].Name != "web_search" { + t.Errorf("expected tool name 'web_search', got %q", exec.normalizedToolCalls[0].Name) + } +} + +func TestPipeline_CallLLM_UsesNativeSearchWithoutClientWebSearchTool(t *testing.T) { + provider := &nativeSearchCaptureProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + if _, ok := agent.Tools.Get("web_search"); ok { + t.Fatal("expected no client-side web_search tool to be registered") + } + + al.cfg.Tools.Web.Enabled = true + al.cfg.Tools.Web.PreferNative = true + + 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) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + if got, _ := provider.lastOpts["native_search"].(bool); !got { + t.Fatalf("expected native_search=true, got %#v", provider.lastOpts["native_search"]) + } +} + +func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) { + errorPrv := &errorProvider{errType: "timeout"} + 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) + } + + // Should retry and eventually fail after max retries + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after retries") + } +} + +func TestPipeline_CallLLM_ContextLengthError(t *testing.T) { + errorPrv := &errorProvider{errType: "context_length"} + 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) + } + + // Should trigger context compression and retry + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + // May succeed after compression or fail - either is acceptable + t.Logf("CallLLM result after context error: err=%v", err) +} + +// ============================================================================= +// Pipeline Method Tests: ExecuteTools +// ============================================================================= + +func TestPipeline_ExecuteTools_NoTools(t *testing.T) { + // Provider returns no tool calls, so ExecuteTools should not be called + // This test verifies the ControlBreak path from CallLLM + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + 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) + } + + // First CallLLM returns ControlBreak (no tools) + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + // No tools to execute, Finalize should be called directly +} + +// ============================================================================= +// runTurn Integration Tests +// ============================================================================= + +func TestRunTurn_SimpleConversation(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-simple") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-simple", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + if result.finalContent == "" { + t.Error("expected non-empty finalContent") + } +} + +func TestRunTurn_MaxIterations(t *testing.T) { + // Provider always returns tool calls, should hit max iterations + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "x"}, + response: "done", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + // Override max iterations to 2 + agent.MaxIterations = 2 + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-maxiter") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-maxiter", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + // Should complete due to max iterations + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +func TestRunTurn_HardAbort(t *testing.T) { + // Provider simulates a slow response, but we'll abort mid-turn + slowProvider := &slowMockProvider{delay: 10 * time.Second} + al, agent, cleanup := newTurnCoordTestLoop(t, slowProvider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-abort") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-abort", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with abort after short delay + done := make(chan struct{}) + + go func() { + al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start + time.Sleep(50 * time.Millisecond) + + // Request hard abort + ts.requestHardAbort() + + // Wait for runTurn to complete + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("runTurn did not complete after abort") + } +} + +func TestRunTurn_SteeringMessageInjection(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-steering") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-steering", + context: newTurnContext(nil, nil, nil), + }) + + // Enqueue steering message before runTurn + steeringMsg := providers.Message{ + Role: "user", + Content: "Steering message", + } + al.Steer(steeringMsg) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + // Steering message should have been injected +} + +func TestRunTurn_GracefulInterrupt(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "test"}, + response: "Final response after interrupt", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-graceful") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-graceful", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with graceful interrupt after first iteration + done := make(chan struct{}) + var result turnResult + + go func() { + result, _ = al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start first iteration + time.Sleep(50 * time.Millisecond) + + // Request graceful interrupt + ts.requestGracefulInterrupt("Please stop") + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runTurn did not complete after graceful interrupt") + } + + // Should complete gracefully + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +// ============================================================================= +// turnState Tests +// ============================================================================= + +func TestTurnState_GracefulInterruptRequested(t *testing.T) { + ts := &turnState{ + gracefulInterrupt: false, + gracefulInterruptHint: "", + } + + // Initially should not be requested + requested, _ := ts.gracefulInterruptRequested() + if requested { + t.Error("expected no interrupt initially") + } + + // Request interrupt + ts.requestGracefulInterrupt("test hint") + + requested, hint := ts.gracefulInterruptRequested() + if !requested { + t.Error("expected interrupt to be requested") + } + if hint != "test hint" { + t.Errorf("expected hint 'test hint', got %q", hint) + } +} + +func TestTurnState_HardAbortRequested(t *testing.T) { + ts := &turnState{ + hardAbort: false, + } + + if ts.hardAbortRequested() { + t.Error("expected no hard abort initially") + } + + ts.requestHardAbort() + + if !ts.hardAbortRequested() { + t.Error("expected hard abort to be requested") + } +} diff --git a/pkg/agent/turn.go b/pkg/agent/turn_state.go similarity index 69% rename from pkg/agent/turn.go rename to pkg/agent/turn_state.go index a061742e3..360c3b7d5 100644 --- a/pkg/agent/turn.go +++ b/pkg/agent/turn_state.go @@ -1,3 +1,5 @@ +// PicoClaw - Ultra-lightweight personal AI agent + package agent import ( @@ -14,6 +16,10 @@ import ( "github.com/sipeed/picoclaw/pkg/tools" ) +// ============================================================================= +// TurnPhase - represents the current phase of a turn +// ============================================================================= + type TurnPhase string const ( @@ -25,6 +31,65 @@ const ( TurnPhaseAborted TurnPhase = "aborted" ) +// ============================================================================= +// Control signals - returned from Pipeline methods to drive runTurn's coordinator loop +// ============================================================================= + +type Control int + +const ( + // ControlContinue tells the coordinator to jump back to the top of the turn loop + // (equivalent to the original "goto turnLoop"). + ControlContinue Control = iota + // ControlBreak tells the coordinator to exit the turn loop and proceed to Finalize. + ControlBreak + // ControlToolLoop tells the coordinator to execute the tool loop. + ControlToolLoop +) + +// ToolControl signals returned from ExecuteTools to drive tool loop iteration. +type ToolControl int + +const ( + // ToolControlContinue tells the tool loop to jump to the next iteration + // (pendingMessages arrived, SubTurn results, etc.). + ToolControlContinue ToolControl = iota + // ToolControlBreak tells the tool loop to exit and return to the coordinator. + ToolControlBreak + // ToolControlFinalize tells the coordinator that all tool responses were + // handled and the turn should finalize without another LLM call. + ToolControlFinalize +) + +// LLMPhase indicates which phase the turn is executing in. +type LLMPhase int + +const ( + LLMPhaseSetup LLMPhase = iota + LLMPhasePreLLM + LLMPhaseLLMCall + LLMPhaseProcessing + LLMPhaseToolLoop + LLMPhaseTools + LLMPhaseFinalizing + LLMPhaseCompleted + LLMPhaseAborted +) + +// ============================================================================= +// turnResult - returned from runTurn +// ============================================================================= + +type turnResult struct { + finalContent string + status TurnEndStatus + followUps []bus.InboundMessage +} + +// ============================================================================= +// ActiveTurnInfo - public info about an active turn +// ============================================================================= + type ActiveTurnInfo struct { TurnID string AgentID string @@ -40,12 +105,70 @@ type ActiveTurnInfo struct { ChildTurnIDs []string } -type turnResult struct { +// ============================================================================= +// turnExecution - mutable state that persists across turn loop iterations +// ============================================================================= + +type turnExecution struct { + // Core message state (accumulates throughout the turn) + messages []providers.Message // built from ContextBuilder, grows per-iteration + pendingMessages []providers.Message // steering/SubTurn messages awaiting injection + history []providers.Message // from ContextManager.Assemble + summary string + + // Turn output finalContent string - status TurnEndStatus - followUps []bus.InboundMessage + + // Iteration tracking + iteration int + + // Per-iteration state set by Pipeline.PreLLM + activeCandidates []providers.FallbackCandidate + activeModel string + activeProvider providers.LLMProvider + usedLight bool + + // LLM call per-iteration state + response *providers.LLMResponse + normalizedToolCalls []providers.ToolCall + allResponsesHandled bool + callMessages []providers.Message + providerToolDefs []providers.ToolDefinition + llmModel string + llmOpts map[string]any + gracefulTerminal bool + useNativeSearch bool + + // Phase tracking + phase LLMPhase + + // Abort signaling for coordinator (set by Pipeline methods) + abortedByHardAbort bool // true when hard abort triggered during LLM/tools + abortedByHook bool // true when HookActionAbortTurn triggered } +// newTurnExecution creates a turnExecution initialized from turnState and options. +func newTurnExecution( + agent *AgentInstance, + opts processOptions, + history []providers.Message, + summary string, + messages []providers.Message, +) *turnExecution { + return &turnExecution{ + history: history, + summary: summary, + messages: messages, + pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...), + iteration: 0, + phase: LLMPhaseSetup, + } +} + +// ============================================================================= +// turnState - the full state for a turn, constructed once per turn +// ============================================================================= + type turnState struct { mu sync.RWMutex @@ -109,6 +232,10 @@ type turnState struct { al *AgentLoop } +// ============================================================================= +// turnState constructors and active turn management +// ============================================================================= + func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState { ts := &turnState{ agent: agent, @@ -145,7 +272,11 @@ func (al *AgentLoop) clearActiveTurn(ts *turnState) { func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState { if val, ok := al.activeTurnStates.Load(sessionKey); ok { - return val.(*turnState) + if ts, ok := val.(*turnState); ok { + return ts + } + // Unexpected non-*turnState value — treat as "no active turn" to avoid + // panics. This should not happen under normal operation. } return nil } @@ -154,8 +285,11 @@ func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState { func (al *AgentLoop) getAnyActiveTurnState() *turnState { var firstTS *turnState al.activeTurnStates.Range(func(key, value any) bool { - firstTS = value.(*turnState) - return false // stop after first + if ts, ok := value.(*turnState); ok { + firstTS = ts + return false + } + return true }) return firstTS } @@ -165,8 +299,11 @@ func (al *AgentLoop) GetActiveTurn() *ActiveTurnInfo { // In the new architecture, there can be multiple concurrent turns var firstTS *turnState al.activeTurnStates.Range(func(key, value any) bool { - firstTS = value.(*turnState) - return false // stop after first + if ts, ok := value.(*turnState); ok { + firstTS = ts + return false + } + return true }) if firstTS == nil { return nil @@ -184,6 +321,10 @@ func (al *AgentLoop) GetActiveTurnBySession(sessionKey string) *ActiveTurnInfo { return &info } +// ============================================================================= +// turnState - getters and setters +// ============================================================================= + func (ts *turnState) snapshot() ActiveTurnInfo { ts.mu.RLock() defer ts.mu.RUnlock() @@ -386,13 +527,12 @@ func (ts *turnState) interruptHintMessage() providers.Message { if hint != "" { content += "\n\nInterrupt hint: " + hint } - return providers.Message{ - Role: "user", - Content: content, - } + return interruptPromptMessage(content) } +// ============================================================================= // SubTurn-related methods +// ============================================================================= // Finish marks the turn as finished and closes the pendingResults channel func (ts *turnState) Finish(isHardAbort bool) { @@ -411,9 +551,9 @@ func (ts *turnState) Finish(isHardAbort bool) { ts.mu.Unlock() }) - // If this is a graceful finish (not hard abort), signal to children - if !isHardAbort && ts.parentTurnState == nil { - // This is a root turn finishing gracefully + // Any graceful finish must signal direct children so nested SubTurns can + // observe parent completion and decide whether to stop or continue. + if !isHardAbort { ts.parentEnded.Store(true) } @@ -429,7 +569,9 @@ func (ts *turnState) Finish(isHardAbort bool) { ts.mu.RUnlock() for _, childID := range children { if val, ok := ts.al.activeTurnStates.Load(childID); ok { - val.(*turnState).Finish(true) + if child, ok := val.(*turnState); ok { + child.Finish(true) + } } } } @@ -481,7 +623,9 @@ func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) { ts.lastUsage = usage } -// Context helper functions for SubTurn +// ============================================================================= +// Context helper functions for turnState +// ============================================================================= type turnStateKeyType struct{} diff --git a/pkg/audio/asr/README_zh.md b/pkg/audio/asr/README.zh.md similarity index 100% rename from pkg/audio/asr/README_zh.md rename to pkg/audio/asr/README.zh.md diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go index d15dc3f09..1482f40bb 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -19,16 +19,16 @@ type TranscriptionResponse struct { Duration float64 `json:"duration,omitempty"` } -func supportsAudioTranscription(model string) bool { - protocol, _ := providers.ExtractProtocol(model) +func supportsAudioTranscription(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) switch protocol { case "openai", "azure", "azure-openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding": + "coding-plan", "alibaba-coding", "qwen-coding", "zai": // These protocols all go through the OpenAI-compatible or Azure provider path in // providers.CreateProviderFromConfig, so they are the only ones that can supply // the audio media payload shape expected by NewAudioModelTranscriber. @@ -41,15 +41,15 @@ func supportsAudioTranscription(model string) bool { } } -func supportsWhisperTranscription(model string) bool { - protocol, _ := providers.ExtractProtocol(model) +func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) switch protocol { case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "mimo": + "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": return true default: return false @@ -61,11 +61,11 @@ func whisperModelID(modelCfg *config.ModelConfig) string { return "" } - if !supportsWhisperTranscription(modelCfg.Model) { + if !supportsWhisperTranscription(modelCfg) { return "" } - _, modelID := providers.ExtractProtocol(strings.TrimSpace(modelCfg.Model)) + _, modelID := providers.ExtractProtocol(modelCfg) if strings.Contains(strings.ToLower(modelID), "whisper") { return modelID } @@ -77,14 +77,14 @@ func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { return nil } - protocol, _ := providers.ExtractProtocol(modelCfg.Model) + protocol, _ := providers.ExtractProtocol(modelCfg) if protocol == "elevenlabs" && modelCfg.APIKey() != "" { return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) } - if supportsAudioTranscription(modelCfg.Model) { + if supportsAudioTranscription(modelCfg) { return NewAudioModelTranscriber(modelCfg) } return nil @@ -95,7 +95,7 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe return nil } - protocol, _ := providers.ExtractProtocol(modelCfg.Model) + protocol, _ := providers.ExtractProtocol(modelCfg) if protocol == "elevenlabs" && modelCfg.APIKey() != "" { return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) } diff --git a/pkg/audio/asr/whisper_transcriber.go b/pkg/audio/asr/whisper_transcriber.go index 406710a8a..fc1101e1c 100644 --- a/pkg/audio/asr/whisper_transcriber.go +++ b/pkg/audio/asr/whisper_transcriber.go @@ -32,7 +32,7 @@ func NewWhisperTranscriber(modelCfg *config.ModelConfig) *WhisperTranscriber { return nil } - protocol, modelID := providers.ExtractProtocol(modelCfg.Model) + protocol, modelID := providers.ExtractProtocol(modelCfg) if modelID == "" { modelID = strings.TrimSpace(modelCfg.Model) } diff --git a/pkg/audio/tts/README_zh.md b/pkg/audio/tts/README.zh.md similarity index 100% rename from pkg/audio/tts/README_zh.md rename to pkg/audio/tts/README.zh.md diff --git a/pkg/audio/tts/tts.go b/pkg/audio/tts/tts.go index 99a9ef203..7ae85c8da 100644 --- a/pkg/audio/tts/tts.go +++ b/pkg/audio/tts/tts.go @@ -24,7 +24,7 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider { return nil } - protocol, modelID := providers.ExtractProtocol(mc.Model) + protocol, modelID := providers.ExtractProtocol(mc) if modelID == "" { modelID = strings.TrimSpace(mc.Model) } diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 2bf719dd4..c03c30d10 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -30,6 +30,15 @@ type OAuthProviderConfig struct { Port int } +type LoginBrowserOptions struct { + NoBrowser bool +} + +var ( + openBrowserFunc = OpenBrowser + browserLoginInput io.Reader = os.Stdin +) + func OpenAIOAuthConfig() OAuthProviderConfig { return OAuthProviderConfig{ Issuer: "https://auth.openai.com", @@ -76,6 +85,10 @@ func GenerateState() (string, error) { } func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { + return LoginBrowserWithOptions(cfg, LoginBrowserOptions{}) +} + +func LoginBrowserWithOptions(cfg OAuthProviderConfig, opts LoginBrowserOptions) (*AuthCredential, error) { pkce, err := GeneratePKCE() if err != nil { return nil, fmt.Errorf("generating PKCE: %w", err) @@ -86,55 +99,45 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { return nil, fmt.Errorf("generating state: %w", err) } - redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port) + redirectURI := oauthCallbackRedirectURI(cfg.Port) + callbackPort := cfg.Port + var resultCh <-chan callbackResult + + if !opts.NoBrowser { + callbackResultCh := make(chan callbackResult, 1) + listener, actualPort, err := listenOAuthCallback(cfg.Port) + if err != nil { + return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) + } + + redirectURI = oauthCallbackRedirectURI(actualPort) + callbackPort = actualPort + resultCh = callbackResultCh + + server := &http.Server{Handler: oauthCallbackHandler(state, callbackResultCh)} + go func() { + _ = server.Serve(listener) + }() + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + }() + } authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI) - resultCh := make(chan callbackResult, 1) - - mux := http.NewServeMux() - mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("state") != state { - resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} - http.Error(w, "State mismatch", http.StatusBadRequest) - return - } - - code := r.URL.Query().Get("code") - if code == "" { - errMsg := r.URL.Query().Get("error") - resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} - http.Error(w, "No authorization code received", http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, "

Authentication successful!

You can close this window.

") - resultCh <- callbackResult{code: code} - }) - - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) - if err != nil { - return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) - } - - server := &http.Server{Handler: mux} - go server.Serve(listener) - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - server.Shutdown(ctx) - }() - fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) - if err := OpenBrowser(authURL); err != nil { + if opts.NoBrowser { + fmt.Println("Browser auto-open disabled. Open the URL manually to continue.") + } else if err := openBrowserFunc(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } fmt.Printf( "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", - cfg.Port, + callbackPort, ) fmt.Println( "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", @@ -142,11 +145,16 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { fmt.Println("Waiting for authentication (browser or manual paste)...") // Start manual input in a goroutine - manualCh := make(chan string) + manualCh := make(chan string, 1) + manualDone := make(chan struct{}) + defer close(manualDone) go func() { - reader := bufio.NewReader(os.Stdin) + reader := bufio.NewReader(browserLoginInput) input, _ := reader.ReadString('\n') - manualCh <- strings.TrimSpace(input) + select { + case manualCh <- strings.TrimSpace(input): + case <-manualDone: + } }() select { @@ -176,6 +184,49 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { } } +func oauthCallbackRedirectURI(port int) string { + return fmt.Sprintf("http://localhost:%d/auth/callback", port) +} + +func oauthCallbackHandler(state string, resultCh chan<- callbackResult) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} + http.Error(w, "State mismatch", http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + errMsg := r.URL.Query().Get("error") + resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} + http.Error(w, "No authorization code received", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "

Authentication successful!

You can close this window.

") + resultCh <- callbackResult{code: code} + }) + return mux +} + +func listenOAuthCallback(port int) (net.Listener, int, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, 0, err + } + + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + _ = listener.Close() + return nil, 0, fmt.Errorf("unexpected listener address type %T", listener.Addr()) + } + + return listener, tcpAddr.Port, nil +} + type callbackResult struct { code string err error diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 230ac7c2a..b318934f9 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -3,6 +3,7 @@ package auth import ( "encoding/base64" "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" @@ -373,3 +374,118 @@ func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) { t.Fatal("expected error for invalid interval") } } + +func TestLoginBrowserWithOptionsNoBrowserDoesNotRequireCallbackPort(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + reservedListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error: %v", err) + } + defer reservedListener.Close() + + reservedPort := reservedListener.Addr().(*net.TCPAddr).Port + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var openCalls int + openBrowserFunc = func(string) error { + openCalls++ + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: reservedPort, + } + + cred, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{NoBrowser: true}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 0 { + t.Fatalf("openBrowserFunc call count = %d, want 0", openCalls) + } + if cred.AccessToken != "mock-access-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token") + } +} + +func TestLoginBrowserWithOptionsAutoOpensByDefault(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var ( + openCalls int + browserURL string + ) + openBrowserFunc = func(url string) error { + openCalls++ + browserURL = url + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: 0, + } + + _, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 1 { + t.Fatalf("openBrowserFunc call count = %d, want 1", openCalls) + } + + parsedBrowserURL, err := url.Parse(browserURL) + if err != nil { + t.Fatalf("url.Parse(browserURL) error: %v", err) + } + + redirectURI, err := url.Parse(parsedBrowserURL.Query().Get("redirect_uri")) + if err != nil { + t.Fatalf("url.Parse(redirectURI) error: %v", err) + } + if redirectURI.Port() == "" { + t.Fatal("redirectURI port is empty") + } + if redirectURI.Port() == "0" { + t.Fatalf("redirectURI port = %q, want dynamically assigned port", redirectURI.Port()) + } +} + +func newMockOAuthTokenServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/token" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + resp := map[string]any{ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + } + _ = json.NewEncoder(w).Encode(resp) + })) +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index dfea11df4..0e6567a03 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -25,6 +26,11 @@ type AuthStore struct { Credentials map[string]*AuthCredential `json:"credentials"` } +const ( + providerGoogleAntigravity = "google-antigravity" + providerAntigravityAlias = "antigravity" +) + func (c *AuthCredential) IsExpired() bool { if c.ExpiresAt.IsZero() { return false @@ -43,6 +49,125 @@ func authFilePath() string { return filepath.Join(config.GetHome(), "auth.json") } +func canonicalProvider(provider string) string { + normalized := strings.ToLower(strings.TrimSpace(provider)) + switch normalized { + case providerAntigravityAlias: + return providerGoogleAntigravity + default: + return normalized + } +} + +func cloneCredential(cred *AuthCredential) *AuthCredential { + if cred == nil { + return nil + } + cp := *cred + return &cp +} + +func mergeCredentials(primary, secondary *AuthCredential) *AuthCredential { + if primary == nil { + return cloneCredential(secondary) + } + + merged := *primary + if secondary == nil { + return &merged + } + if merged.AccessToken == "" { + merged.AccessToken = secondary.AccessToken + } + if merged.RefreshToken == "" { + merged.RefreshToken = secondary.RefreshToken + } + if merged.AccountID == "" { + merged.AccountID = secondary.AccountID + } + if merged.ExpiresAt.IsZero() { + merged.ExpiresAt = secondary.ExpiresAt + } + if merged.Provider == "" { + merged.Provider = secondary.Provider + } + if merged.AuthMethod == "" { + merged.AuthMethod = secondary.AuthMethod + } + if merged.Email == "" { + merged.Email = secondary.Email + } + if merged.ProjectID == "" { + merged.ProjectID = secondary.ProjectID + } + + return &merged +} + +func shouldPreferCredential( + candidate *AuthCredential, + candidateCanonical bool, + current *AuthCredential, + currentCanonical bool, +) bool { + if candidate == nil { + return false + } + if current == nil { + return true + } + + switch { + case candidate.ExpiresAt.After(current.ExpiresAt): + return true + case current.ExpiresAt.After(candidate.ExpiresAt): + return false + case candidateCanonical != currentCanonical: + return candidateCanonical + default: + return false + } +} + +func normalizeStore(store *AuthStore) { + if store == nil { + return + } + if store.Credentials == nil { + store.Credentials = make(map[string]*AuthCredential) + return + } + + normalized := make(map[string]*AuthCredential, len(store.Credentials)) + canonicalFlags := make(map[string]bool, len(store.Credentials)) + + for provider, cred := range store.Credentials { + normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) + canonical := canonicalProvider(provider) + normalizedCred := cloneCredential(cred) + if normalizedCred != nil { + normalizedCred.Provider = canonicalProvider(normalizedCred.Provider) + if normalizedCred.Provider == "" { + normalizedCred.Provider = canonical + } + } + + current := normalized[canonical] + currentCanonical := canonicalFlags[canonical] + candidateCanonical := normalizedProvider == canonical + + if shouldPreferCredential(normalizedCred, candidateCanonical, current, currentCanonical) { + normalized[canonical] = mergeCredentials(normalizedCred, current) + canonicalFlags[canonical] = candidateCanonical + continue + } + + normalized[canonical] = mergeCredentials(current, normalizedCred) + } + + store.Credentials = normalized +} + func LoadStore() (*AuthStore, error) { path := authFilePath() data, err := os.ReadFile(path) @@ -57,9 +182,7 @@ func LoadStore() (*AuthStore, error) { if err := json.Unmarshal(data, &store); err != nil { return nil, err } - if store.Credentials == nil { - store.Credentials = make(map[string]*AuthCredential) - } + normalizeStore(&store) return &store, nil } @@ -79,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) { if err != nil { return nil, err } - cred, ok := store.Credentials[provider] + cred, ok := store.Credentials[canonicalProvider(provider)] if !ok { return nil, nil } @@ -91,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error { if err != nil { return err } - store.Credentials[provider] = cred + + canonical := canonicalProvider(provider) + normalized := cloneCredential(cred) + if normalized != nil { + normalized.Provider = canonicalProvider(normalized.Provider) + if normalized.Provider == "" { + normalized.Provider = canonical + } + } + + store.Credentials[canonical] = normalized return SaveStore(store) } @@ -100,7 +233,7 @@ func DeleteCredential(provider string) error { if err != nil { return err } - delete(store.Credentials, provider) + delete(store.Credentials, canonicalProvider(provider)) return SaveStore(store) } diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index f6793cfce..578ed4ead 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -1,12 +1,24 @@ package auth import ( + "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) +func setTestAuthHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestAuthCredentialIsExpired(t *testing.T) { tests := []struct { name string @@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) { } func TestStoreRoundtrip(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "test-access-token", @@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) { } func TestStoreFilePermissions(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + tmpDir := setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "secret-token", @@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() + if runtime.GOOS == "windows" { + return + } if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } func TestStoreMultiProvider(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} @@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) { } func TestDeleteCredential(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} if err := SetCredential("openai", cred); err != nil { @@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) { } func TestLoadStoreEmpty(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) store, err := LoadStore() if err != nil { @@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } + +func TestGetCredentialCanonicalizesLegacyAntigravityProvider(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "project-1", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cred, err := GetCredential("google-antigravity") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if cred == nil { + t.Fatal("GetCredential() returned nil") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } +} + +func TestLoadStoreMergesAntigravityAliasesPreferringNewerExpiry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + refreshedExpiry := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + "google-antigravity": map[string]any{ + "access_token": "fresh-token", + "expires_at": refreshedExpiry.Format(time.RFC3339), + "provider": "google-antigravity", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestLoadStorePrefersCanonicalKeyWhenExpiryMatchesAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + " Google-Antigravity ": map[string]any{ + "access_token": "fresh-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": " Google-Antigravity ", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } +} + +func TestSetCredentialReplacesLegacyAntigravityEntry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC).Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = SetCredential("google-antigravity", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestDeleteCredentialRemovesLegacyAntigravityAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err = DeleteCredential(" google-antigravity ") + if err != nil { + t.Fatalf("DeleteCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 0 { + t.Fatalf("credential count = %d, want 0", len(loaded.Credentials)) + } +} + +func TestSetCredentialCanonicalizesTrimmedMixedCaseProvider(t *testing.T) { + setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC) + if err := SetCredential(" AnTiGrAvItY ", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: expiresAt, + Provider: " AnTiGrAvItY ", + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } + + got, err := GetCredential(" GoOgLe-AnTiGrAvItY ") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if got == nil { + t.Fatal("GetCredential() returned nil") + } + if got.Provider != "google-antigravity" { + t.Fatalf("GetCredential provider = %q, want %q", got.Provider, "google-antigravity") + } +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index aa06ca173..953e69d9c 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -61,6 +61,15 @@ type OutboundScope struct { Values map[string]string `json:"values,omitempty"` } +// ContextUsage describes how much of the model's context window the current +// session consumes, and how far it is from triggering compression. +type ContextUsage struct { + UsedTokens int `json:"used_tokens"` + TotalTokens int `json:"total_tokens"` // model context window + CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression + UsedPercent int `json:"used_percent"` // 0-100 +} + type OutboundMessage struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` @@ -70,6 +79,7 @@ type OutboundMessage struct { Scope *OutboundScope `json:"scope,omitempty"` Content string `json:"content"` ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ContextUsage *ContextUsage `json:"context_usage,omitempty"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 28f7277d3..514b9b3b1 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,9 +45,12 @@ type DiscordChannel struct { cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking + progress *channels.ToolFeedbackAnimator + botUserID string // stored for mention checking bus *bus.MessageBus tts tts.TTSProvider + playTTSFn func(context.Context, *discordgo.VoiceConnection, string, uint64) + ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool) voiceMu sync.RWMutex voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID @@ -84,7 +87,7 @@ func NewDiscordChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &DiscordChannel{ + ch := &DiscordChannel{ BaseChannel: base, bc: bc, session: session, @@ -93,7 +96,11 @@ func NewDiscordChannel( typingStop: make(map[string]chan struct{}), bus: bus, voiceSSRC: make(map[string]map[uint32]string), - }, nil + } + ch.playTTSFn = ch.playTTS + ch.ttsVoiceFn = ch.voiceConnectionForTTS + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *DiscordChannel) Start(ctx context.Context) error { @@ -142,6 +149,9 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) @@ -164,32 +174,88 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s return nil, nil } - if c.tts != nil { - if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" { - if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil { - // Cancel any previous TTS playback - c.ttsMu.Lock() - if c.cancelTTS != nil { - c.cancelTTS() - } - ttsCtx, ttsCancel := context.WithCancel(c.ctx) - c.ttsPlayID++ - playID := c.ttsPlayID - c.cancelTTS = ttsCancel - c.ttsMu.Unlock() - - go c.playTTS(ttsCtx, vc, msg.Content, playID) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled { + if err != nil { + return nil, err } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) + c.maybeStartTTS(channelID, msg.Content, isToolFeedback) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil } } - msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID) if err != nil { return nil, err } + if isToolFeedback { + c.RecordToolFeedbackMessage(channelID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{msgID}, nil } +func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) { + if c.tts == nil || isToolFeedback { + return + } + + voiceFn := c.ttsVoiceFn + if voiceFn == nil { + voiceFn = c.voiceConnectionForTTS + } + vc, ok := voiceFn(channelID) + if !ok || vc == nil { + return + } + + // Cancel any previous TTS playback. + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + } + ttsCtx, ttsCancel := context.WithCancel(c.ctx) + c.ttsPlayID++ + playID := c.ttsPlayID + c.cancelTTS = ttsCancel + playFn := c.playTTSFn + c.ttsMu.Unlock() + + if playFn == nil { + playFn = c.playTTS + } + go playFn(ttsCtx, vc, content, playID) +} + +func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) { + if c.session == nil || c.session.State == nil { + return nil, false + } + + ch, err := c.session.State.Channel(channelID) + if err != nil || ch == nil || ch.GuildID == "" { + return nil, false + } + + vc, ok := c.session.VoiceConnections[ch.GuildID] + if !ok || vc == nil { + return nil, false + } + return vc, true +} + // SendMedia implements the channels.MediaSender interface. func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { @@ -200,6 +266,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if channelID == "" { return nil, fmt.Errorf("channel ID is empty") } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) store := c.GetMediaStore() if store == nil { @@ -281,6 +348,9 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if r.err != nil { return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers @@ -295,10 +365,15 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - _, err := c.session.ChannelMessageEdit(chatID, messageID, content) + _, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx)) return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx)) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). @@ -317,6 +392,81 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f4..d42b0bc52 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,13 +1,37 @@ package discord import ( + "context" + "io" "net/http" + "net/http/httptest" "net/url" + "reflect" + "sync" "testing" + "time" "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" ) +type stubTTSProvider struct{} + +func (stubTTSProvider) Name() string { return "stub-tts" } + +func (stubTTSProvider) Synthesize(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(&noopReader{}), nil +} + +type noopReader struct{} + +func (*noopReader) Read(p []byte) (int, error) { + return 0, io.EOF +} + func TestApplyDiscordProxy_CustomProxy(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { @@ -89,3 +113,224 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback message to be cleared") + } + + mu.Lock() + defer mu.Unlock() + wantRequests := []string{ + "PATCH /channels/chat-1/messages/prog-1", + } + if !reflect.DeepEqual(requests, wantRequests) { + t.Fatalf("requests = %v, want %v", requests, wantRequests) + } +} + +func TestEditMessage_UsesContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return + case <-time.After(time.Second): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"msg-1"}`) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err = ch.EditMessage(ctx, "chat-1", "msg-1", "still running") + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected EditMessage() to fail when context times out") + } + if elapsed >= 500*time.Millisecond { + t.Fatalf("EditMessage() ignored context timeout, elapsed=%v", elapsed) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &DiscordChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if got, want := msgIDs, []string{"msg-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want %v", got, want) + } +} + +func TestSend_NonToolFeedbackFinalizerStillStartsTTS(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ttsStarted := make(chan string, 1) + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + tts: tts.TTSProvider(stubTTSProvider{}), + } + ch.ttsVoiceFn = func(string) (*discordgo.VoiceConnection, bool) { + return &discordgo.VoiceConnection{}, true + } + ch.playTTSFn = func(_ context.Context, _ *discordgo.VoiceConnection, text string, _ uint64) { + ttsStarted <- text + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + + select { + case got := <-ttsStarted: + if got != "final reply" { + t.Fatalf("TTS content = %q, want final reply", got) + } + case <-time.After(2 * time.Second): + t.Fatal("expected TTS to start for finalized tracked tool feedback reply") + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 02ee47d69..8f3ae39d9 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -49,6 +49,9 @@ type FeishuChannel struct { mu sync.Mutex cancel context.CancelFunc + + progress *channels.ToolFeedbackAnimator + deleteMessageFn func(context.Context, string, string) error } type cachedMessage struct { @@ -74,6 +77,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M tokenCache: tc, client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } + ch.deleteMessageFn = ch.deleteMessageAPI + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.SetOwner(ch) return ch, nil } @@ -132,6 +137,9 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } c.wsClient = nil c.mu.Unlock() + if c.progress != nil { + c.progress.StopAll() + } c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") @@ -149,17 +157,55 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + // Feishu can fall back to plain text for a previous progress + // message, and those messages cannot be patched through the card + // edit API. Drop the stale tracker and recreate the progress + // message so later tool feedback is not blocked. + c.resetTrackedToolFeedbackAfterEditFailure(ctx, msg.ChatID) + } else { + return []string{msgID}, nil + } + } + } else { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + // Build interactive card with markdown content - cardContent, err := buildMarkdownCard(msg.Content) + sendContent := msg.Content + if isToolFeedback { + sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + cardContent, err := buildMarkdownCard(sendContent) if err != nil { // If card build fails, fall back to plain text - return nil, c.sendText(ctx, msg.ChatID, msg.Content) + msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent) + if sendErr != nil { + return nil, sendErr + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // First attempt: try sending as interactive card - err = c.sendCard(ctx, msg.ChatID, cardContent) + msgID, err := c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // Check if error is due to card table limit (error code 11310) @@ -174,9 +220,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st }) // Second attempt: fall back to plain text message - textErr := c.sendText(ctx, msg.ChatID, msg.Content) + msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent) if textErr == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // If text also fails, return the text error return nil, textErr @@ -210,6 +261,31 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return nil } +// DeleteMessage implements channels.MessageDeleter. +func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error { + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + return deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) deleteMessageAPI(ctx context.Context, chatID, messageID string) error { + req := larkim.NewDeleteMessageReqBuilder(). + MessageId(messageID). + Build() + + resp, err := c.client.Im.V1.Message.Delete(ctx, req) + if err != nil { + return fmt.Errorf("feishu delete: %w", err) + } + if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + return fmt.Errorf("feishu delete api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { @@ -251,6 +327,93 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *FeishuChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) resetTrackedToolFeedbackAfterEditFailure(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + _ = deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + // ReactToMessage implements channels.ReactionCapable. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it. func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { @@ -323,6 +486,7 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) if msg.ChatID == "" { return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) @@ -339,6 +503,10 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return nil, nil } @@ -801,7 +969,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { } // sendCard sends an interactive card message to a chat. -func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { +func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) { req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). @@ -813,23 +981,26 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send card: %w", channels.ErrTemporary) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) - return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendText sends a plain text message to a chat (fallback when card fails). -func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) { content, _ := json.Marshal(map[string]string{"text": text}) req := larkim.NewCreateMessageReqBuilder(). @@ -843,18 +1014,21 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send text: %w", channels.ErrTemporary) } if !resp.Success() { - return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendImage uploads an image and sends it as a message. diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 9010abf69..48fdf0f74 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,9 +3,13 @@ package feishu import ( + "context" + "errors" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/channels" ) func TestExtractContent(t *testing.T) { @@ -279,3 +283,110 @@ func TestExtractFeishuSenderID(t *testing.T) { }) } } + +func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after successful edit") + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_EditFailureKeepsTrackedMessage(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(context.Context, string, string, string) error { + return errors.New("edit failed") + }, + ) + if handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to report unhandled on edit failure") + } + if len(msgIDs) != 0 { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if msgID, ok := ch.currentToolFeedbackMessage("chat-1"); !ok || msgID != "msg-1" { + t.Fatalf("expected tracked tool feedback to remain after failed edit, got (%q, %v)", msgID, ok) + } +} + +func TestResetTrackedToolFeedbackAfterEditFailure_DismissesTrackedMessage(t *testing.T) { + var ( + deletedChatID string + deletedMsgID string + ) + + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + deleteMessageFn: func(_ context.Context, chatID, messageID string) error { + deletedChatID = chatID + deletedMsgID = messageID + return nil + }, + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + ch.resetTrackedToolFeedbackAfterEditFailure(context.Background(), "chat-1") + + if deletedChatID != "chat-1" || deletedMsgID != "msg-1" { + t.Fatalf("unexpected delete target: chat=%q msg=%q", deletedChatID, deletedMsgID) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after edit failure reset") + } +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 928676cbc..d56c4fd9b 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "sort" + "strings" "sync" "time" @@ -25,6 +26,7 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -96,6 +98,23 @@ type Manager struct { channelHashes map[string]string // channel name → config hash } +type toolFeedbackMessageTracker interface { + RecordToolFeedbackMessage(chatID, messageID, content string) + ClearToolFeedbackMessage(chatID string) +} + +type toolFeedbackMessageCleaner interface { + DismissToolFeedbackMessage(ctx context.Context, chatID string) +} + +type toolFeedbackMessageTargetResolver interface { + ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string +} + +type toolFeedbackMessageContentPreparer interface { + PrepareToolFeedbackMessageContent(content string) string +} + type asyncTask struct { cancel context.CancelFunc } @@ -108,6 +127,21 @@ func outboundMessageChatID(msg bus.OutboundMessage) string { return msg.ChatID } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func outboundMessageBypassesPlaceholderEdit(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + kind := strings.TrimSpace(msg.Context.Raw["message_kind"]) + return strings.EqualFold(kind, "thought") || strings.EqualFold(kind, "tool_calls") +} + func outboundMediaChannel(msg bus.OutboundMediaMessage) string { return msg.Context.Channel } @@ -116,6 +150,68 @@ func outboundMediaChatID(msg bus.OutboundMediaMessage) string { return msg.ChatID } +func trackedToolFeedbackMessageChatID(ch Channel, chatID string, outboundCtx *bus.InboundContext) string { + if resolver, ok := ch.(toolFeedbackMessageTargetResolver); ok { + if resolved := strings.TrimSpace(resolver.ToolFeedbackMessageChatID(chatID, outboundCtx)); resolved != "" { + return resolved + } + } + return strings.TrimSpace(chatID) +} + +func dismissTrackedToolFeedbackMessage( + ctx context.Context, + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, +) { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok { + cleaner.DismissToolFeedbackMessage(ctx, trackedChatID) + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + +func clearTrackedToolFeedbackMessage( + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, +) { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + +func prepareToolFeedbackMessageContent(ch Channel, content string) string { + prepared := strings.TrimSpace(content) + if prepared == "" { + return "" + } + if preparer, ok := ch.(toolFeedbackMessageContentPreparer); ok { + if candidate := strings.TrimSpace(preparer.PrepareToolFeedbackMessageContent(prepared)); candidate != "" { + return candidate + } + } + return prepared +} + +func (m *Manager) toolFeedbackSeparateMessagesEnabled() bool { + if m == nil || m.config == nil { + return false + } + return m.config.Agents.Defaults.IsToolFeedbackSeparateMessagesEnabled() +} + // RecordPlaceholder registers a placeholder message for later editing. // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { @@ -196,7 +292,20 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 3. If a stream already finalized this message, delete the placeholder and skip send + isToolFeedback := outboundMessageIsToolFeedback(msg) + separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled() + + // 3. If a stream already finalized this chat, stale tool feedback must be + // dropped without consuming the final-response marker. Streaming finalization + // bypasses the worker queue, so older queued feedback can arrive before the + // normal final outbound message that cleans up the marker and placeholder. + if isToolFeedback { + if _, loaded := m.streamActive.Load(key); loaded { + return nil, true + } + } + + // 4. If a stream already finalized this message, delete the placeholder and skip send if _, loaded := m.streamActive.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { @@ -208,14 +317,49 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } } + if !isToolFeedback { + if separateToolFeedbackMessages { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } else { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } + } return nil, true } - // 4. Try editing placeholder + if separateToolFeedbackMessages { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } + + // 5. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { + if isToolFeedback && separateToolFeedbackMessages { + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } + return nil, false + } + if outboundMessageBypassesPlaceholderEdit(msg) { + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } + return nil, false + } if editor, ok := ch.(MessageEditor); ok { - if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil { + content := msg.Content + trackedContent := msg.Content + if isToolFeedback { + trackedContent = prepareToolFeedbackMessageContent(ch, msg.Content) + content = InitialAnimatedToolFeedbackContent(trackedContent) + } + if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, &msg.Context) + if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback { + tracker.RecordToolFeedbackMessage(trackedChatID, entry.id, trackedContent) + } else if !isToolFeedback { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } return []string{entry.id}, true } // edit failed → fall through to normal Send @@ -251,6 +395,10 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun // 3. Clear any finalized stream marker for this chat before media delivery. m.streamActive.LoadAndDelete(key) + if m.toolFeedbackSeparateMessagesEnabled() { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } + // 4. Delete placeholder if present. if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { @@ -312,22 +460,46 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) ( // Mark streamActive on Finalize so preSend knows to clean up the placeholder key := channelName + ":" + chatID return &finalizeHookStreamer{ - Streamer: streamer, - onFinalize: func() { m.streamActive.Store(key, true) }, + Streamer: streamer, + onFinalize: func(finalizeCtx context.Context) { + if m.toolFeedbackSeparateMessagesEnabled() { + clearTrackedToolFeedbackMessage( + ch, + chatID, + &bus.InboundContext{ + Channel: channelName, + ChatID: chatID, + }, + ) + } else { + dismissTrackedToolFeedbackMessage( + finalizeCtx, + ch, + chatID, + &bus.InboundContext{ + Channel: channelName, + ChatID: chatID, + }, + ) + } + m.streamActive.Store(key, true) + }, }, true } // finalizeHookStreamer wraps a Streamer to run a hook on Finalize. type finalizeHookStreamer struct { Streamer - onFinalize func() + onFinalize func(context.Context) } func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error { if err := s.Streamer.Finalize(ctx, content); err != nil { return err } - s.onFinalize() + if s.onFinalize != nil { + s.onFinalize(ctx) + } return nil } @@ -769,18 +941,21 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) // Collect all message chunks to send var chunks []string - // Step 1: Try marker-based splitting if enabled - if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { + // Step 1: Try marker-based splitting if enabled. + // Tool feedback must stay a single message, so it skips marker splitting. + if m.config != nil && m.config.Agents.Defaults.SplitOnMarker && !outboundMessageIsToolFeedback(msg) { if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { for _, chunk := range markerChunks { - chunks = append(chunks, splitByLength(chunk, maxLen)...) + chunkMsg := msg + chunkMsg.Content = chunk + chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...) } } } // Step 2: Fallback to length-based splitting if no chunks from marker if len(chunks) == 0 { - chunks = splitByLength(msg.Content, maxLen) + chunks = splitOutboundMessageContent(msg, maxLen) } // Step 3: Send all chunks @@ -795,12 +970,25 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } -// splitByLength splits content by maxLen if needed, otherwise returns single chunk. -func splitByLength(content string, maxLen int) []string { - if maxLen > 0 && len([]rune(content)) > maxLen { - return SplitMessage(content, maxLen) +// splitOutboundMessageContent splits regular outbound content by maxLen, but +// keeps tool feedback in a single message by truncating the explanation body. +func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string { + if maxLen > 0 { + if outboundMessageIsToolFeedback(msg) { + animationSafeLen := maxLen - MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxLen + } + if len([]rune(msg.Content)) > animationSafeLen { + return []string{utils.FitToolFeedbackMessage(msg.Content, animationSafeLen)} + } + return []string{msg.Content} + } + if len([]rune(msg.Content)) > maxLen { + return SplitMessage(msg.Content, maxLen) + } } - return []string{content} + return []string{msg.Content} } // sendWithRetry sends a message through the channel with rate limiting and @@ -1264,13 +1452,16 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - for _, chunk := range SplitMessage(msg.Content, maxLen) { + if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 { + for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk m.sendWithRetry(ctx, channelName, w, chunkMsg) } } else { + if len(chunks) == 1 { + msg.Content = chunks[0] + } m.sendWithRetry(ctx, channelName, w, msg) } return nil diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 881993d9c..6c518780d 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" ) // mockChannel is a test double that delegates Send to a configurable function. @@ -76,8 +78,9 @@ func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM type mockDeletingMediaChannel struct { mockMediaChannel - deleteCalls int - lastDeleted struct { + deleteCalls int + dismissedChatID string + lastDeleted struct { chatID string messageID string } @@ -94,6 +97,48 @@ func (m *mockDeletingMediaChannel) DeleteMessage( return nil } +func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +type mockStreamer struct { + finalizeFn func(context.Context, string) error +} + +func (m *mockStreamer) Update(context.Context, string) error { return nil } + +func (m *mockStreamer) Finalize(ctx context.Context, content string) error { + if m.finalizeFn != nil { + return m.finalizeFn(ctx, content) + } + return nil +} + +func (m *mockStreamer) Cancel(context.Context) {} + +type mockStreamingChannel struct { + mockMessageEditor + streamer Streamer + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +func (m *mockStreamingChannel) BeginStream(context.Context, string) (Streamer, error) { + if m.streamer == nil { + return nil, errors.New("missing streamer") + } + return m.streamer, nil +} + +func (m *mockStreamingChannel) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} + // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ @@ -715,13 +760,86 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { // mockMessageEditor is a channel that supports MessageEditor. type mockMessageEditor struct { mockChannel - editFn func(ctx context.Context, chatID, messageID, content string) error + editFn func(ctx context.Context, chatID, messageID, content string) error + finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) + finalizeCalled bool + recordedChatID string + recordedMessageID string + recordedContent string + clearedChatID string + dismissedChatID string } func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { return m.editFn(ctx, chatID, messageID, content) } +func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, content string) { + m.recordedChatID = chatID + m.recordedMessageID = messageID + m.recordedContent = content +} + +func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) { + m.clearedChatID = chatID +} + +func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +func (m *mockMessageEditor) FinalizeToolFeedbackMessage( + ctx context.Context, + msg bus.OutboundMessage, +) ([]string, bool) { + m.finalizeCalled = true + if m.finalizeFn == nil { + return nil, false + } + return m.finalizeFn(ctx, msg) +} + +type mockResolvedToolFeedbackEditor struct { + mockMessageEditor + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +type mockDeletingMessageEditor struct { + mockMessageEditor + deleteCalls int + deletedChatID string + deletedMessageID string +} + +func (m *mockDeletingMessageEditor) DeleteMessage(_ context.Context, chatID, messageID string) error { + m.deleteCalls++ + m.deletedChatID = chatID + m.deletedMessageID = messageID + return nil +} + +func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} + +type mockPreparedToolFeedbackEditor struct { + mockMessageEditor + prepareFn func(content string) string +} + +func (m *mockPreparedToolFeedbackEditor) PrepareToolFeedbackMessageContent(content string) string { + if m.prepareFn != nil { + return m.prepareFn(content) + } + return content +} + func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m := newTestManager() var sendCalled bool @@ -766,6 +884,810 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { } } +func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) { + m := newTestManager() + + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "123" || ch.recordedMessageID != "456" { + t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesResolvedTrackedChatID(t *testing.T) { + m := newTestManager() + + ch := &mockResolvedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "-100123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if chatID != "-100123" { + t.Fatalf("expected raw chat ID, got %q", chatID) + } + if outboundCtx == nil || outboundCtx.TopicID != "42" { + t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx) + } + return chatID + "/" + outboundCtx.TopicID + }, + } + + m.RecordPlaceholder("test", "-100123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "-100123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "-100123", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "-100123/42" || ch.recordedMessageID != "456" { + t.Fatalf("expected resolved tracked message -100123/42/456, got %q/%q", + ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesPreparedContent(t *testing.T) { + m := newTestManager() + + const rawContent = "🔧 `read_file`\n" + "" + const preparedContent = "🔧 `read_file`\n<raw>" + + ch := &mockPreparedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" { + t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) + } + if content != InitialAnimatedToolFeedbackContent(preparedContent) { + t.Fatalf("unexpected prepared content: %q", content) + } + return nil + }, + }, + prepareFn: func(content string) string { + if content != rawContent { + t.Fatalf("unexpected raw tool feedback: %q", content) + } + return preparedContent + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: rawContent, + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedContent != preparedContent { + t.Fatalf("expected tracked content %q, got %q", preparedContent, ch.recordedContent) + } +} + +func TestPreSend_NonToolFeedbackLeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{} + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if edited { + t.Fatal("expected preSend to fall through when no placeholder exists") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback cleanup to be deferred to channel send, got %q", ch.dismissedChatID) + } +} + +func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{ + finalizeFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, bool) { + if msg.ChatID != "123" || msg.Content != "final reply" { + t.Fatalf("unexpected finalize msg: %+v", msg) + } + return []string{"tool-msg-1"}, true + }, + } + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf("expected preSend to defer to channel Send, got msgIDs=%v", msgIDs) + } + if len(msgIDs) != 0 { + t.Fatalf("expected no msgIDs from preSend, got %v", msgIDs) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked cleanup to remain in channel Send, got %q", ch.dismissedChatID) + } + if ch.finalizeCalled { + t.Fatal("expected preSend to skip channel tool feedback finalization") + } +} + +func TestPreSend_ToolFeedbackSeparateMessagesDeletesPlaceholderAndSkipsEdit(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected placeholder edit to be skipped in separate message mode") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf("expected preSend to fall through so the channel can send a new message, got %v", msgIDs) + } + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if ch.recordedMessageID != "" { + t.Fatalf("expected no tracked placeholder record, got %q", ch.recordedMessageID) + } + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback state to be cleared before sending, got %q", ch.clearedChatID) + } +} + +func TestPreSend_ThoughtPlaceholderDeleteAndSkipsEdit(t *testing.T) { + m := newTestManager() + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected thought message to bypass placeholder edit") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "thinking trace", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "thought", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf( + "expected thought message to fall through so the channel can send a structured message, got %v", + msgIDs, + ) + } + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if _, ok := m.placeholders.Load("test:123"); ok { + t.Fatal("expected placeholder to be consumed before structured thought send") + } +} + +func TestSendWithRetry_ToolCallsPlaceholderDeleteAndFallsThroughToSend(t *testing.T) { + m := newTestManager() + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + if got := msg.Context.Raw["message_kind"]; got != "tool_calls" { + t.Fatalf("expected tool_calls message kind, got %q", got) + } + if msg.Content != "" { + t.Fatalf("expected empty tool_calls content, got %q", msg.Content) + } + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected tool_calls message to bypass placeholder edit") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_calls", + "tool_calls": `[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Looking up config"}}]`, + }, + }, + }) + + m.sendWithRetry(context.Background(), "test", w, msg) + + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if len(ch.sentMessages) != 1 { + t.Fatalf("expected structured tool_calls message to be sent once, got %d", len(ch.sentMessages)) + } +} + +func TestPreSend_NonToolFeedbackSeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + + ch := &mockMessageEditor{} + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatal("expected preSend to leave final delivery to the channel") + } + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback state to be cleared, got %q", ch.clearedChatID) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID) + } + if ch.finalizeCalled { + t.Fatal("expected separate message mode to skip in-place finalization") + } +} + +func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) { + m := newTestManager() + m.streamActive.Store("test:123", true) + m.RecordPlaceholder("test", "123", "placeholder-1") + + var editedContent string + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "placeholder-1" { + t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) + } + editedContent = content + return nil + }, + } + + toolFeedback := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\nReading config", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", toolFeedback, ch) + if !handled { + t.Fatal("expected stale tool feedback to be dropped after stream finalize") + } + if len(msgIDs) != 0 { + t.Fatalf("expected no delivered message IDs for stale feedback, got %v", msgIDs) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to remain for the final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); !ok { + t.Fatal("expected placeholder cleanup to remain deferred to the final outbound message") + } + if ch.editedMessages != 0 { + t.Fatalf("expected no placeholder edit for stale feedback, got %d edits", ch.editedMessages) + } + + finalMsg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final streamed reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, handled = m.preSend(context.Background(), "test", finalMsg, ch) + if !handled { + t.Fatal("expected final outbound message to consume streamActive marker") + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected streamActive marker to be cleared by final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); ok { + t.Fatal("expected placeholder to be cleaned up by final outbound message") + } + if editedContent != "final streamed reply" { + t.Fatalf("editedContent = %q, want final streamed reply", editedContent) + } +} + +func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockDeletingMediaChannel{} + + m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{ + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }, ch) + + if ch.dismissedChatID != "" { + t.Fatalf( + "expected tracked tool feedback cleanup to be deferred to channel media send, got %q", + ch.dismissedChatID, + ) + } +} + +func TestPreSendMedia_SeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + + ch := &mockMessageEditor{} + + m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{ + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }, ch) + + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback state to be cleared before media delivery, got %q", ch.clearedChatID) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback message to be preserved"+ + " for media delivery, got %q", ch.dismissedChatID) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure before editing the config example.", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, 40) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + want := utils.FitToolFeedbackMessage(msg.Content, 40-MaxToolFeedbackAnimationFrameLength()) + if chunks[0] != want { + t.Fatalf("chunk = %q, want %q", chunks[0], want) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackReservesAnimationFrame(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\n1234567890", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, len([]rune(msg.Content))) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + + animated := formatAnimatedToolFeedbackContent(chunks[0], strings.Repeat(".", MaxToolFeedbackAnimationFrameLength())) + if got, maxLen := len([]rune(animated)), len([]rune(msg.Content)); got > maxLen { + t.Fatalf("animated len = %d, want <= %d; content=%q", got, maxLen, animated) + } +} + +func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.dismissedChatID != "123" { + t.Fatalf("expected tracked tool feedback to be dismissed for chat 123, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestGetStreamer_FinalizeSeparateMessagesClearsTrackedToolFeedback(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback to be cleared for chat 123, got %q", ch.clearedChatID) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestGetStreamer_FinalizeDismissesResolvedTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if outboundCtx == nil { + t.Fatal("expected outbound context during stream finalize") + } + if outboundCtx.ChatID != "-100123/42" { + t.Fatalf("unexpected outbound context: %+v", outboundCtx) + } + return outboundCtx.ChatID + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "-100123/42") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.dismissedChatID != "-100123/42" { + t.Fatalf("expected resolved tracked tool feedback dismissal, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:-100123/42"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestPreSend_PlaceholderEditSuccessDismissesResolvedTrackedToolFeedback(t *testing.T) { + m := newTestManager() + + ch := &mockResolvedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "-100123" || messageID != "456" || content != "done" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if outboundCtx == nil || outboundCtx.TopicID != "42" { + t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx) + } + return chatID + "/" + outboundCtx.TopicID + }, + } + + m.RecordPlaceholder("test", "-100123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "-100123", + Content: "done", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "-100123", + TopicID: "42", + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.dismissedChatID != "-100123/42" { + t.Fatalf("expected resolved tracked dismissal, got %q", ch.dismissedChatID) + } +} + +func TestGetStreamer_FinalizeFailureDoesNotDismissTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(context.Context, string) error { + return errors.New("finalize failed") + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err == nil { + t.Fatal("expected Finalize() to fail") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected no tool feedback dismissal on finalize failure, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected no streamActive marker after finalize failure") + } +} + +func TestRunWorker_ToolFeedbackSkipsMarkerSplitting(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + SplitOnMarker: true, + }, + }, + } + + var ( + mu sync.Mutex + received []string + ) + ch := &mockChannelWithLength{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + mu.Lock() + received = append(received, msg.Content) + mu.Unlock() + return nil + }, + }, + maxLen: 200, + } + + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 1), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.runWorker(ctx, "test", w) + + content := "🔧 `read_file`\nRead current config first.<|[SPLIT]|>Then update the example." + w.queue <- testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: content, + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 1 { + t.Fatalf("len(received) = %d, want 1", len(received)) + } + if received[0] != content { + t.Fatalf("received[0] = %q, want %q", received[0], content) + } +} + func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m := newTestManager() diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 40e1b0a36..04599d6d2 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -46,6 +46,13 @@ const ( var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + type roomKindCacheEntry struct { isGroup bool expiresAt time.Time @@ -192,6 +199,7 @@ type MatrixChannel struct { cryptoHelper *cryptohelper.CryptoHelper cryptoDbPath string + progress *channels.ToolFeedbackAnimator } func NewMatrixChannel( @@ -236,7 +244,7 @@ func NewMatrixChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &MatrixChannel{ + ch := &MatrixChannel{ BaseChannel: base, bc: bc, client: client, @@ -248,7 +256,9 @@ func NewMatrixChannel( localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, cryptoDbPath: cryptoDatabasePath, - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *MatrixChannel) Start(ctx context.Context) error { @@ -297,6 +307,9 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { c.cancel() } c.stopTypingSessions(ctx) + if c.progress != nil { + c.progress.StopAll() + } // Close crypto helper if initialized if c.cryptoHelper != nil { @@ -398,11 +411,36 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, nil } + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(content) + } + resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) } - return []string{resp.EventID.String()}, nil + msgID := resp.EventID.String() + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { @@ -419,6 +457,8 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + sendCtx := ctx if sendCtx == nil { sendCtx = context.Background() @@ -529,6 +569,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return eventIDs, nil } @@ -612,6 +656,89 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *MatrixChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + roomID := id.RoomID(strings.TrimSpace(chatID)) + if roomID == "" { + return fmt.Errorf("matrix room ID is empty") + } + eventID := id.EventID(strings.TrimSpace(messageID)) + if eventID == "" { + return fmt.Errorf("matrix message ID is empty") + } + + _, err := c.client.RedactEvent(ctx, roomID, eventID) + return err +} + +func (c *MatrixChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *MatrixChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *MatrixChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *MatrixChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *MatrixChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *MatrixChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *MatrixChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *MatrixChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) { if !c.config.JoinOnInvite { return diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 07f08f32b..066f08059 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -14,6 +14,7 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) @@ -41,6 +42,34 @@ func TestMatrixLocalpartMentionRegexp(t *testing.T) { } } +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &MatrixChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("!room:matrix.org", "$event1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "!room:matrix.org", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "!room:matrix.org" || messageID != "$event1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "$event1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [$event1]", msgIDs) + } +} + func TestStripUserMention(t *testing.T) { userID := id.UserID("@picoclaw:matrix.org") diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index f998712c8..9bd8a5b5d 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -5,7 +5,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "mime" "net/http" + "net/url" + "os" + "path/filepath" "strings" "sync" "sync/atomic" @@ -19,6 +23,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) // picoConn represents a single WebSocket connection. @@ -46,6 +51,26 @@ func outboundMessageIsThought(msg bus.OutboundMessage) bool { return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought) } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func outboundMessageIsToolCalls(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindToolCalls) +} + +func outboundMessageFinalizesTrackedToolFeedback(msg bus.OutboundMessage) bool { + return !outboundMessageIsToolFeedback(msg) && + !outboundMessageIsThought(msg) && + !outboundMessageIsToolCalls(msg) +} + // writeJSON sends a JSON message to the connection with write locking. func (pc *picoConn) writeJSON(v any) error { if pc.closed.Load() { @@ -78,6 +103,8 @@ type PicoChannel struct { connsMu sync.RWMutex ctx context.Context cancel context.CancelFunc + progress *channels.ToolFeedbackAnimator + deleteMessageFn func(context.Context, string, string) error } // NewPicoChannel creates a new Pico Protocol channel. @@ -106,7 +133,7 @@ func NewPicoChannel( return false } - return &PicoChannel{ + ch := &PicoChannel{ BaseChannel: base, bc: bc, config: cfg, @@ -117,7 +144,10 @@ func NewPicoChannel( }, connections: make(map[string]*picoConn), sessionConnections: make(map[string]map[string]*picoConn), - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.deleteMessageFn = ch.DeleteMessage + return ch, nil } // createAndAddConnection checks MaxConnections and registers a connection atomically. @@ -235,6 +265,9 @@ func (c *PicoChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } logger.InfoC("pico", "Pico Protocol channel stopped") return nil @@ -251,6 +284,10 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "/ws", "/ws/": c.handleWebSocket(w, r) default: + if strings.HasPrefix(path, "/media/") { + c.handleMediaDownload(w, r) + return + } http.NotFound(w, r) } } @@ -261,24 +298,140 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri return nil, channels.ErrNotRunning } isThought := outboundMessageIsThought(msg) + isToolFeedback := outboundMessageIsToolFeedback(msg) + isToolCalls := outboundMessageIsToolCalls(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if outboundMessageFinalizesTrackedToolFeedback(msg) { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } - outMsg := newMessage(TypeMessageCreate, map[string]any{ - PayloadKeyContent: msg.Content, + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID := uuid.New().String() + + payload := map[string]any{ + PayloadKeyContent: content, PayloadKeyThought: isThought, - }) + "message_id": msgID, + } + if isToolCalls { + payload[PayloadKeyKind] = MessageKindToolCalls + if toolCalls, ok := picoToolCallsPayload(msg); ok { + payload[PayloadKeyToolCalls] = toolCalls + } + } + setContextUsagePayload(payload, msg.ContextUsage) + outMsg := newMessage(TypeMessageCreate, payload) - return nil, c.broadcastToSession(msg.ChatID, outMsg) + if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { + return nil, err + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg && outboundMessageFinalizesTrackedToolFeedback(msg) { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // EditMessage implements channels.MessageEditor. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - outMsg := newMessage(TypeMessageUpdate, map[string]any{ + return c.editMessage(ctx, chatID, messageID, content, nil) +} + +// DeleteMessage implements channels.MessageDeleter. +func (c *PicoChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + outMsg := newMessage(TypeMessageDelete, map[string]any{ "message_id": messageID, - "content": content, }) return c.broadcastToSession(chatID, outMsg) } +func (c *PicoChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *PicoChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *PicoChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *PicoChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *PicoChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *PicoChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.DeleteMessage + } + _ = deleteFn(ctx, chatID, messageID) +} + +func (c *PicoChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string, *bus.ContextUsage) error, + contextUsage *bus.ContextUsage, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content, contextUsage); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if !outboundMessageFinalizesTrackedToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.editMessage, msg.ContextUsage) +} + // StartTyping implements channels.TypingCapable. func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { startMsg := newMessage(TypeTypingStart, nil) @@ -315,6 +468,210 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin return msgID, nil } +// SendMedia implements channels.MediaSender for the Pico web UI. +// Media is delivered as a normal assistant message carrying structured +// attachments plus an authenticated same-origin download URL. +func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + + store := c.GetMediaStore() + if store == nil { + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + attachments := make([]map[string]any, 0, len(msg.Parts)) + caption := "" + + for _, part := range msg.Parts { + localPath, meta, err := store.ResolveWithMeta(part.Ref) + if err != nil { + logger.ErrorCF("pico", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + filename := strings.TrimSpace(part.Filename) + if filename == "" { + filename = strings.TrimSpace(meta.Filename) + } + if filename == "" { + filename = filepath.Base(localPath) + } + + contentType := strings.TrimSpace(part.ContentType) + if contentType == "" { + contentType = strings.TrimSpace(meta.ContentType) + } + if contentType == "" { + contentType = "application/octet-stream" + } + + attachmentType := strings.TrimSpace(part.Type) + if attachmentType == "" { + attachmentType = picoInferAttachmentType(filename, contentType) + } + + attachmentURL, err := picoDownloadURLForRef(part.Ref) + if err != nil { + logger.ErrorCF("pico", "Failed to build media download URL", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + attachments = append(attachments, map[string]any{ + "type": attachmentType, + "url": attachmentURL, + "filename": filename, + "content_type": contentType, + }) + + if caption == "" && strings.TrimSpace(part.Caption) != "" { + caption = strings.TrimSpace(part.Caption) + } + } + + if len(attachments) == 0 { + return nil, fmt.Errorf("no deliverable media parts: %w", channels.ErrSendFailed) + } + + msgID := uuid.New().String() + outMsg := newMessage(TypeMessageCreate, map[string]any{ + PayloadKeyContent: caption, + "attachments": attachments, + "message_id": msgID, + }) + + if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { + return nil, err + } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + + return []string{msgID}, nil +} + +func picoDownloadURLForRef(ref string) (string, error) { + refID, err := picoMediaRefID(ref) + if err != nil { + return "", err + } + return "/pico/media/" + url.PathEscape(refID), nil +} + +func picoMediaRefID(ref string) (string, error) { + refID := strings.TrimSpace(strings.TrimPrefix(ref, "media://")) + if refID == "" || strings.Contains(refID, "/") { + return "", fmt.Errorf("invalid media ref %q", ref) + } + return refID, nil +} + +func picoInferAttachmentType(filename, contentType string) string { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + filename = strings.ToLower(strings.TrimSpace(filename)) + + switch { + case strings.HasPrefix(contentType, "image/"): + return "image" + case strings.HasPrefix(contentType, "audio/"): + return "audio" + case strings.HasPrefix(contentType, "video/"): + return "video" + } + + switch ext := filepath.Ext(filename); ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + default: + return "file" + } +} + +func picoAllowsInlineDisplay(filename, contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + filename = strings.ToLower(strings.TrimSpace(filename)) + + if strings.Contains(contentType, "svg") || filepath.Ext(filename) == ".svg" { + return false + } + + return picoInferAttachmentType(filename, contentType) == "image" +} + +func (c *PicoChannel) handleMediaDownload(w http.ResponseWriter, r *http.Request) { + if !c.IsRunning() { + http.Error(w, "channel not running", http.StatusServiceUnavailable) + return + } + if !c.authenticate(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + refID := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/pico/media/"), "/")) + if refID == "" { + http.NotFound(w, r) + return + } + + store := c.GetMediaStore() + if store == nil { + http.Error(w, "media store unavailable", http.StatusServiceUnavailable) + return + } + + localPath, meta, err := store.ResolveWithMeta("media://" + refID) + if err != nil { + http.NotFound(w, r) + return + } + + file, err := os.Open(localPath) + if err != nil { + http.Error(w, "failed to open media", http.StatusInternalServerError) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + http.Error(w, "failed to stat media", http.StatusInternalServerError) + return + } + + filename := strings.TrimSpace(meta.Filename) + if filename == "" { + filename = filepath.Base(localPath) + } + contentType := strings.TrimSpace(meta.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + + dispositionType := "attachment" + if picoAllowsInlineDisplay(filename, contentType) { + dispositionType = "inline" + } + + if cd := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); cd != "" { + w.Header().Set("Content-Disposition", cd) + } + w.Header().Set("Content-Type", contentType) + http.ServeContent(w, r, filename, info.ModTime(), file) +} + // broadcastToSession sends a message to all connections with a matching session. func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { // chatID format: "pico:" @@ -716,3 +1073,45 @@ func validateInlineImageDataURL(mediaURL string) error { return nil } + +// setContextUsagePayload adds context window usage stats to a pico payload. +func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) { + if u == nil { + return + } + payload["context_usage"] = map[string]any{ + "used_tokens": u.UsedTokens, + "total_tokens": u.TotalTokens, + "compress_at_tokens": u.CompressAtTokens, + "used_percent": u.UsedPercent, + } +} + +func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) { + raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls]) + if raw == "" { + return nil, false + } + + var toolCalls []utils.VisibleToolCall + if err := json.Unmarshal([]byte(raw), &toolCalls); err != nil || len(toolCalls) == 0 { + return nil, false + } + return toolCalls, true +} + +func (c *PicoChannel) editMessage( + ctx context.Context, + chatID string, + messageID string, + content string, + contextUsage *bus.ContextUsage, +) error { + payload := map[string]any{ + "message_id": messageID, + "content": content, + } + setContextUsagePayload(payload, contextUsage) + outMsg := newMessage(TypeMessageUpdate, payload) + return c.broadcastToSession(chatID, outMsg) +} diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 59db705eb..22ed5451a 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -4,12 +4,21 @@ import ( "context" "errors" "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" "sync" "testing" + "time" + + "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) func newTestPicoChannel(t *testing.T) *PicoChannel { @@ -27,6 +36,163 @@ func newTestPicoChannel(t *testing.T) *PicoChannel { return ch } +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &PicoChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "pico:chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string, contextUsage *bus.ContextUsage) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "pico:chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + if contextUsage != nil { + t.Fatalf("unexpected context usage: %+v", contextUsage) + } + return nil + }, + nil, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [msg-1]", msgIDs) + } +} + +func TestDismissTrackedToolFeedbackMessage_DeletesProgressMessage(t *testing.T) { + ch := &PicoChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") + + var deleted struct { + chatID string + messageID string + } + ch.deleteMessageFn = func(_ context.Context, chatID string, messageID string) error { + deleted.chatID = chatID + deleted.messageID = messageID + return nil + } + + ch.DismissToolFeedbackMessage(context.Background(), "pico:chat-1") + + if deleted.chatID != "pico:chat-1" || deleted.messageID != "msg-1" { + t.Fatalf("unexpected delete target: %+v", deleted) + } + if _, ok := ch.currentToolFeedbackMessage("pico:chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after dismissal") + } +} + +func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { + ch := newTestPicoChannel(t) + + 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"}) + + ch.RecordToolFeedbackMessage("pico:sess-1", "msg-progress", "🔧 `read_file`\nReading config") + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "pico:sess-1", + Content: "thinking trace", + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + Raw: map[string]string{ + "message_kind": MessageKindThought, + }, + }, + }); err != nil { + t.Fatalf("Send(thought) error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("thought message type = %q, want %q", msg.Type, TypeMessageCreate) + } + payload := msg.Payload + 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["message_id"]; got == "msg-progress" || got == nil || got == "" { + t.Fatalf("thought message_id = %#v, want new non-progress id", got) + } + case <-time.After(time.Second): + t.Fatal("expected thought message to be delivered") + } + + if msgID, ok := ch.currentToolFeedbackMessage("pico:sess-1"); !ok || msgID != "msg-progress" { + t.Fatalf("tracked tool feedback = (%q, %v), want (msg-progress, true)", msgID, ok) + } + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "pico:sess-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + }, + ContextUsage: &bus.ContextUsage{ + UsedTokens: 321, + TotalTokens: 4096, + CompressAtTokens: 3072, + UsedPercent: 8, + }, + }); err != nil { + t.Fatalf("Send(final) error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageUpdate { + t.Fatalf("final message type = %q, want %q", msg.Type, TypeMessageUpdate) + } + payload := msg.Payload + if got := payload["message_id"]; got != "msg-progress" { + t.Fatalf("final message_id = %#v, want %q", got, "msg-progress") + } + if got := payload[PayloadKeyContent]; got != "final reply" { + t.Fatalf("final content = %#v, want %q", got, "final reply") + } + rawUsage, ok := payload["context_usage"].(map[string]any) + if !ok { + t.Fatalf("final context_usage = %#v, want map payload", payload["context_usage"]) + } + if got, ok := rawUsage["used_tokens"].(float64); !ok || got != 321 { + t.Fatalf("used_tokens = %#v, want 321", rawUsage["used_tokens"]) + } + if got, ok := rawUsage["total_tokens"].(float64); !ok || got != 4096 { + t.Fatalf("total_tokens = %#v, want 4096", rawUsage["total_tokens"]) + } + case <-time.After(time.Second): + t.Fatal("expected final reply to finalize tracked tool feedback") + } + + if _, ok := ch.currentToolFeedbackMessage("pico:sess-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after final reply") + } +} + func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) { ch := newTestPicoChannel(t) @@ -123,6 +289,167 @@ func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) { } } +func TestSendMedia_ResolvesMediaBeforeDelivery(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + closedConn := &picoConn{id: "closed", sessionID: "sess-1"} + closedConn.closed.Store(true) + ch.addConnForTest(closedConn) + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "pico:sess-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) + } +} + +func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + 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"}) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + ch.RecordToolFeedbackMessage("pico:sess-1", "msg-progress", "🔧 `read_file`") + + var deleted struct { + chatID string + messageID string + } + ch.deleteMessageFn = func(_ context.Context, chatID string, messageID string) error { + deleted.chatID = chatID + deleted.messageID = messageID + return nil + } + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "pico:sess-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate) + } + case <-time.After(time.Second): + t.Fatal("expected media message to be delivered") + } + + if deleted.chatID != "pico:sess-1" || deleted.messageID != "msg-progress" { + t.Fatalf("unexpected delete target: %+v", deleted) + } + if _, ok := ch.currentToolFeedbackMessage("pico:sess-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after media delivery") + } +} + +func TestPicoDownloadURLForRef(t *testing.T) { + got, err := picoDownloadURLForRef("media://attachment-1") + if err != nil { + t.Fatalf("picoDownloadURLForRef() error = %v", err) + } + if got != "/pico/media/attachment-1" { + t.Fatalf("picoDownloadURLForRef() = %q, want %q", got, "/pico/media/attachment-1") + } +} + +func TestHandleMediaDownload_ServesStoredFile(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("downloadable"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + refID := strings.TrimPrefix(ref, "media://") + req := httptest.NewRequest("GET", "/pico/media/"+refID, nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if body := rec.Body.String(); body != "downloadable" { + t.Fatalf("body = %q, want %q", body, "downloadable") + } + if got := rec.Header().Get("Content-Type"); got != "text/plain" { + t.Fatalf("Content-Type = %q, want %q", got, "text/plain") + } +} + func (c *PicoChannel) addConnForTest(pc *picoConn) { c.connsMu.Lock() defer c.connsMu.Unlock() @@ -143,3 +470,39 @@ func (c *PicoChannel) addConnForTest(pc *picoConn) { } bySession[pc.id] = pc } + +func newTestPicoWebSocket(t *testing.T) (*websocket.Conn, <-chan PicoMessage, func()) { + t.Helper() + + received := make(chan PicoMessage, 4) + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("Upgrade() error = %v", err) + return + } + defer conn.Close() + for { + var msg PicoMessage + if err := conn.ReadJSON(&msg); err != nil { + return + } + received <- msg + } + })) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + clientConn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + server.Close() + t.Fatalf("Dial() error = %v", err) + } + + cleanup := func() { + clientConn.Close() + server.Close() + } + defer resp.Body.Close() + return clientConn, received, cleanup +} diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index ecdc2d140..46e8fa3ee 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -12,18 +12,20 @@ const ( // TypeMessageCreate is sent from server to client. TypeMessageCreate = "message.create" TypeMessageUpdate = "message.update" + TypeMessageDelete = "message.delete" TypeMediaCreate = "media.create" TypeTypingStart = "typing.start" TypeTypingStop = "typing.stop" TypeError = "error" TypePong = "pong" - PicoTokenPrefix = "pico-" + PayloadKeyContent = "content" + PayloadKeyThought = "thought" + PayloadKeyKind = "kind" + PayloadKeyToolCalls = "tool_calls" - PayloadKeyContent = "content" - PayloadKeyThought = "thought" - - MessageKindThought = "thought" + MessageKindThought = "thought" + MessageKindToolCalls = "tool_calls" ) // PicoMessage is the wire format for all Pico Protocol messages. diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go index d3152ec3d..c6b362601 100644 --- a/pkg/channels/telegram/command_registration.go +++ b/pkg/channels/telegram/command_registration.go @@ -66,6 +66,10 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c if register == nil { register = c.RegisterCommands } + delayFn := c.commandRegDelayFn + if delayFn == nil { + delayFn = commandRegistrationDelay + } regCtx, cancel := context.WithCancel(ctx) c.commandRegCancel = cancel @@ -91,7 +95,7 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c return } - delay := commandRegistrationDelay(attempt) + delay := delayFn(attempt) logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{ "error": err.Error(), "retry_after": delay.String(), diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go index 26f891b2e..c30c6f68d 100644 --- a/pkg/channels/telegram/command_registration_test.go +++ b/pkg/channels/telegram/command_registration_test.go @@ -31,14 +31,12 @@ func TestStartCommandRegistration_DoesNotBlock(t *testing.T) { } func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() - var attempts atomic.Int32 ch.registerFunc = func(context.Context, []commands.Definition) error { n := attempts.Add(1) @@ -69,12 +67,10 @@ func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { } func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) - - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() defer cancel() var attempts atomic.Int32 diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go index 95dc3e9d6..0614b6e32 100644 --- a/pkg/channels/telegram/parser_markdown_to_html.go +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -2,9 +2,13 @@ package telegram import ( "fmt" + "html" + "regexp" "strings" ) +var reRawURL = regexp.MustCompile(`https?://[^\s<]+`) + func markdownToTelegramHTML(text string) string { if text == "" { return "" @@ -19,6 +23,9 @@ func markdownToTelegramHTML(text string) string { links := extractLinks(text) text = links.text + rawURLs := extractRawURLs(text) + text = rawURLs.text + text = reHeading.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1") @@ -43,10 +50,19 @@ func markdownToTelegramHTML(text string) string { for i, lnk := range links.links { label := escapeHTML(lnk[0]) - url := lnk[1] + url := escapeHTMLAttr(lnk[1]) text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`%s`, url, label)) } + for i, rawURL := range rawURLs.urls { + escaped := escapeHTML(rawURL) + text = strings.ReplaceAll( + text, + fmt.Sprintf("\x00RU%d\x00", i), + fmt.Sprintf(`%s`, escapeHTMLAttr(rawURL), escaped), + ) + } + for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) @@ -92,6 +108,11 @@ type codeBlockMatch struct { codes []string } +type rawURLMatch struct { + text string + urls []string +} + func extractCodeBlocks(text string) codeBlockMatch { matches := reCodeBlock.FindAllStringSubmatch(text, -1) @@ -110,6 +131,24 @@ func extractCodeBlocks(text string) codeBlockMatch { return codeBlockMatch{text: text, codes: codes} } +func extractRawURLs(text string) rawURLMatch { + matches := reRawURL.FindAllString(text, -1) + + urls := make([]string, 0, len(matches)) + for _, match := range matches { + urls = append(urls, match) + } + + i := 0 + text = reRawURL.ReplaceAllStringFunc(text, func(string) string { + placeholder := fmt.Sprintf("\x00RU%d\x00", i) + i++ + return placeholder + }) + + return rawURLMatch{text: text, urls: urls} +} + type inlineCodeMatch struct { text string codes []string @@ -139,3 +178,7 @@ func escapeHTML(text string) string { text = strings.ReplaceAll(text, ">", ">") return text } + +func escapeHTMLAttr(text string) string { + return html.EscapeString(text) +} diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go index 7754ee076..a54a1c2c7 100644 --- a/pkg/channels/telegram/parser_markdown_to_html_test.go +++ b/pkg/channels/telegram/parser_markdown_to_html_test.go @@ -32,6 +32,11 @@ func Test_markdownToTelegramHTML(t *testing.T) { input: "[click here](https://example.com/path)", expected: `click here`, }, + { + name: "raw oauth url with underscores survives", + input: "Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256", + expected: `Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256`, + }, { name: "link with underscores in URL is not corrupted by italic regex", // Google Flights URLs use URL-safe base64 with underscores in the tfs param. @@ -45,6 +50,11 @@ func Test_markdownToTelegramHTML(t *testing.T) { input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)", expected: `first and second`, }, + { + name: "markdown link query params are escaped in href", + input: "[oauth](https://example.com/cb?response_type=code&client_id=test-client)", + expected: `oauth`, + }, { name: "link label with HTML special chars is escaped", input: "[a & b](https://example.com)", @@ -55,6 +65,11 @@ func Test_markdownToTelegramHTML(t *testing.T) { input: "a & b < c > d", expected: "a & b < c > d", }, + { + name: "code block with language", + input: "```json\n{\n \"path\": \"README.md\"\n}\n```", + expected: "
{\n  \"path\": \"README.md\"\n}\n
", + }, } for _, tc := range cases { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 2a9cfe4ae..cebebfed6 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -45,16 +45,18 @@ var ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *th.BotHandler - bc *config.Channel - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc - tgCfg *config.TelegramSettings + bot *telego.Bot + bh *th.BotHandler + bc *config.Channel + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc + tgCfg *config.TelegramSettings + progress *channels.ToolFeedbackAnimator - registerFunc func(context.Context, []commands.Definition) error - commandRegCancel context.CancelFunc + registerFunc func(context.Context, []commands.Definition) error + commandRegDelayFn func(int) time.Duration + commandRegCancel context.CancelFunc } func NewTelegramChannel( @@ -104,13 +106,15 @@ func NewTelegramChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &TelegramChannel{ + ch := &TelegramChannel{ BaseChannel: base, bot: bot, bc: bc, chatIDs: make(map[string]int64), tgCfg: telegramCfg, - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *TelegramChannel) Start(ctx context.Context) error { @@ -168,6 +172,9 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if c.commandRegCancel != nil { c.commandRegCancel() } @@ -191,12 +198,36 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] return nil, nil } + isToolFeedback := outboundMessageIsToolFeedback(msg) + toolFeedbackContent := msg.Content + if isToolFeedback { + toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096) + } + trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, trackedChatID, toolFeedbackContent); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID) + if !isToolFeedback { + if msgIDs, handled := c.finalizeToolFeedbackMessageForChat(ctx, trackedChatID, msg); handled { + return msgIDs, nil + } + } + // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID var messageIDs []string queue := []string{msg.Content} + if isToolFeedback { + queue = []string{channels.InitialAnimatedToolFeedbackContent(toolFeedbackContent)} + } for len(queue) > 0 { chunk := queue[0] queue = queue[1:] @@ -204,6 +235,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] content := parseContent(chunk, useMarkdownV2) if len([]rune(content)) > 4096 { + if isToolFeedback { + fittedChunk := fitToolFeedbackForTelegram(chunk, useMarkdownV2, 4096) + if fittedChunk != "" && fittedChunk != chunk { + queue = append([]string{fittedChunk}, queue...) + continue + } + } runeChunk := []rune(chunk) ratio := float64(len(runeChunk)) / float64(len([]rune(content))) smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin @@ -270,6 +308,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] replyToID = "" } + if isToolFeedback && len(messageIDs) > 0 { + c.RecordToolFeedbackMessage(trackedChatID, messageIDs[0], toolFeedbackContent) + } else if !isToolFeedback && hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID) + } + return messageIDs, nil } @@ -437,6 +481,89 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess }) } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *TelegramChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *TelegramChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *TelegramChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *TelegramChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *TelegramChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeToolFeedbackMessageForChat(ctx, telegramToolFeedbackChatKey(msg.ChatID, &msg.Context), msg) +} + +func (c *TelegramChannel) finalizeToolFeedbackMessageForChat( + ctx context.Context, + chatID string, + msg bus.OutboundMessage, +) ([]string, bool) { + return c.finalizeTrackedToolFeedbackMessage(ctx, chatID, msg.Content, c.EditMessage) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). @@ -468,6 +595,8 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context) + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID) chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { @@ -576,6 +705,10 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID) + } + return messageIDs, nil } @@ -947,6 +1080,60 @@ func parseContent(text string, useMarkdownV2 bool) string { return markdownToTelegramHTML(text) } +func fitToolFeedbackForTelegram(content string, useMarkdownV2 bool, maxParsedLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxParsedLen <= 0 { + return "" + } + animationSafeLen := maxParsedLen - channels.MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxParsedLen + } + if len([]rune(parseContent(content, useMarkdownV2))) <= animationSafeLen { + return content + } + + low := 1 + high := len([]rune(content)) + best := utils.Truncate(content, 1) + + for low <= high { + mid := (low + high) / 2 + candidate := utils.FitToolFeedbackMessage(content, mid) + if candidate == "" { + high = mid - 1 + continue + } + if len([]rune(parseContent(candidate, useMarkdownV2))) <= animationSafeLen { + best = candidate + low = mid + 1 + continue + } + high = mid - 1 + } + + return best +} + +func (c *TelegramChannel) PrepareToolFeedbackMessageContent(content string) string { + if c == nil || c.tgCfg == nil { + return strings.TrimSpace(content) + } + return fitToolFeedbackForTelegram(content, c.tgCfg.UseMarkdownV2, 4096) +} + +func telegramToolFeedbackChatKey(chatID string, outboundCtx *bus.InboundContext) string { + resolvedChatID, threadID, err := resolveTelegramOutboundTarget(chatID, outboundCtx) + if err != nil || threadID == 0 { + return strings.TrimSpace(chatID) + } + return fmt.Sprintf("%d/%d", resolvedChatID, threadID) +} + +func (c *TelegramChannel) ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string { + return telegramToolFeedbackChatKey(chatID, outboundCtx) +} + // parseTelegramChatID splits "chatID/threadID" into its components. // Returns threadID=0 when no "/" is present (non-forum messages). func parseTelegramChatID(chatID string) (int64, int, error) { @@ -1097,7 +1284,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann return nil, fmt.Errorf("streaming disabled in config") } - cid, _, err := parseTelegramChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return nil, err } @@ -1106,6 +1293,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann return &telegramStreamer{ bot: c.bot, chatID: cid, + threadID: threadID, draftID: cryptoRandInt(), throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second, minGrowth: streamCfg.MinGrowthChars, @@ -1118,6 +1306,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann type telegramStreamer struct { bot *telego.Bot chatID int64 + threadID int draftID int throttleInterval time.Duration minGrowth int @@ -1145,10 +1334,11 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error { htmlContent := markdownToTelegramHTML(content) err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{ - ChatID: s.chatID, - DraftID: s.draftID, - Text: htmlContent, - ParseMode: telego.ModeHTML, + ChatID: s.chatID, + MessageThreadID: s.threadID, + DraftID: s.draftID, + Text: htmlContent, + ParseMode: telego.ModeHTML, }) if err != nil { // First error → degrade silently (e.g. no forum mode) @@ -1167,6 +1357,7 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error { func (s *telegramStreamer) Finalize(ctx context.Context, content string) error { htmlContent := markdownToTelegramHTML(content) tgMsg := tu.Message(tu.ID(s.chatID), htmlContent) + tgMsg.MessageThreadID = s.threadID tgMsg.ParseMode = telego.ModeHTML if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil { diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 614b2ca7f..20b2004a9 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -108,7 +108,7 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() select { case <-ctx.Done(): diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 3d147b337..69c76b430 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -98,8 +98,12 @@ func (s *multipartRecordingConstructor) MultipartRequest( // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { + return successResponseWithMessageID(t, 1) +} + +func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response { t.Helper() - msg := &telego.Message{MessageID: 1} + msg := &telego.Message{MessageID: messageID} b, err := json.Marshal(msg) require.NoError(t, err) return &ta.Response{Ok: true, Result: b} @@ -142,6 +146,7 @@ func newTestChannelWithConstructor( chatIDs: make(map[string]int64), bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true}, tgCfg: &config.TelegramSettings{}, + progress: channels.NewToolFeedbackAnimator(nil), } } @@ -266,6 +271,176 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") } +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + switch { + case strings.Contains(url, "editMessageText"): + return successResponseWithMessageID(t, 1), nil + default: + t.Fatalf("unexpected API call: %s", url) + return nil, nil + } + }, + } + ch := newTestChannel(t, caller) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "final reply", + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"1"}, ids) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "editMessageText") + _, ok := ch.currentToolFeedbackMessage("12345") + assert.False(t, ok, "tracked tool feedback should be cleared after final reply") +} + +func TestSend_ToolFeedbackTrackingIsTopicScoped(t *testing.T) { + nextMessageID := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + nextMessageID++ + return successResponseWithMessageID(t, nextMessageID), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "🔧 `read_file`", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + require.NoError(t, err) + + _, ok := ch.currentToolFeedbackMessage("-1001234567890") + assert.False(t, ok, "base chat should not track topic-specific tool feedback") + + msgID, ok := ch.currentToolFeedbackMessage("-1001234567890/42") + require.True(t, ok, "topic chat should track tool feedback") + assert.Equal(t, "1", msgID) +} + +func TestSend_TopicReplyDoesNotFinalizeDifferentTopicToolFeedback(t *testing.T) { + nextMessageID := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + nextMessageID++ + return successResponseWithMessageID(t, nextMessageID), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "🔧 `read_file`", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + require.NoError(t, err) + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "final reply in another topic", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "43", + }, + }) + require.NoError(t, err) + require.Len(t, caller.calls, 2) + assert.Equal(t, []string{"2"}, ids) + assert.Contains(t, caller.calls[1].URL, "sendMessage") + assert.NotContains(t, caller.calls[1].URL, "editMessageText") + + _, ok := ch.currentToolFeedbackMessage("-1001234567890/42") + assert.True(t, ok, "tool feedback in the original topic should remain tracked") +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := newTestChannel(t, &stubCaller{ + callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) { + t.Fatal("unexpected API call") + return nil, nil + }, + }) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "12345", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + _, ok := ch.currentToolFeedbackMessage(chatID) + assert.False(t, ok, "tracked tool feedback should be stopped before edit") + assert.Equal(t, "12345", chatID) + assert.Equal(t, "1", messageID) + assert.Equal(t, "final reply", content) + return nil + }, + ) + + assert.True(t, handled) + assert.Equal(t, []string{"1"}, msgIDs) +} + +func TestSend_ToolFeedbackStaysSingleMessageAfterHTMLExpansion(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "🔧 `read_file`\n" + strings.Repeat("<", 2000), + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "12345", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1, "tool feedback should stay a single Telegram message after HTML escaping") +} + +func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) { + content := "🔧 `read_file`\n" + strings.Repeat("a", 4096) + + fitted := fitToolFeedbackForTelegram(content, false, 4096) + animated := strings.Replace( + fitted, + "`\n", + strings.Repeat(".", channels.MaxToolFeedbackAnimationFrameLength())+"`\n", + 1, + ) + + if got := len([]rune(parseContent(animated, false))); got > 4096 { + t.Fatalf("animated parsed length = %d, want <= 4096", got) + } +} + func TestSend_LongMessage_SingleCall(t *testing.T) { // With WithMaxMessageLength(4000), the Manager pre-splits messages before // they reach Send(). A message at exactly 4000 chars should go through @@ -560,6 +735,58 @@ func TestSend_UsesContextTopicIDWhenChatIDDoesNotIncludeThread(t *testing.T) { assert.Equal(t, "Hello from topic context", params.Text) } +func TestBeginStream_UpdateUsesForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return &ta.Response{Ok: true, Result: []byte("true")}, nil + }, + } + ch := newTestChannel(t, caller) + ch.tgCfg.Streaming.Enabled = true + + streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42") + require.NoError(t, err) + require.NoError(t, streamer.Update(context.Background(), "partial")) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendMessageDraft") + + var params struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(-1001234567890), params.ChatID) + assert.Equal(t, 42, params.MessageThreadID) + assert.Equal(t, "partial", params.Text) +} + +func TestBeginStream_FinalizeUsesForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + ch.tgCfg.Streaming.Enabled = true + + streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42") + require.NoError(t, err) + require.NoError(t, streamer.Finalize(context.Background(), "final")) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendMessage") + + var params struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(-1001234567890), params.ChatID) + assert.Equal(t, 42, params.MessageThreadID) + assert.Equal(t, "final", params.Text) +} + func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ diff --git a/pkg/channels/tool_feedback_animator.go b/pkg/channels/tool_feedback_animator.go new file mode 100644 index 000000000..b424612bf --- /dev/null +++ b/pkg/channels/tool_feedback_animator.go @@ -0,0 +1,240 @@ +package channels + +import ( + "context" + "strings" + "sync" + "time" +) + +const toolFeedbackAnimationInterval = 3 * time.Second + +const initialToolFeedbackAnimationFrame = "" + +var toolFeedbackAnimationFrames = []string{"..", "."} + +// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length +// so callers can reserve room before sending messages to length-limited APIs. +func MaxToolFeedbackAnimationFrameLength() int { + maxLen := len([]rune(initialToolFeedbackAnimationFrame)) + for _, frame := range toolFeedbackAnimationFrames { + if frameLen := len([]rune(frame)); frameLen > maxLen { + maxLen = frameLen + } + } + return maxLen +} + +type toolFeedbackAnimationState struct { + messageID string + baseContent string + stop chan struct{} + done chan struct{} +} + +type ToolFeedbackAnimator struct { + mu sync.Mutex + editFn func(ctx context.Context, chatID, messageID, content string) error + entries map[string]*toolFeedbackAnimationState +} + +func NewToolFeedbackAnimator( + editFn func(ctx context.Context, chatID, messageID, content string) error, +) *ToolFeedbackAnimator { + return &ToolFeedbackAnimator{ + editFn: editFn, + entries: make(map[string]*toolFeedbackAnimationState), + } +} + +func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", false + } + a.mu.Lock() + defer a.mu.Unlock() + entry, ok := a.entries[chatID] + if !ok || strings.TrimSpace(entry.messageID) == "" { + return "", false + } + return entry.messageID, true +} + +func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) { + if a == nil { + return + } + chatID = strings.TrimSpace(chatID) + messageID = strings.TrimSpace(messageID) + content = strings.TrimSpace(content) + if chatID == "" || messageID == "" || content == "" { + return + } + + entry := &toolFeedbackAnimationState{ + messageID: messageID, + baseContent: content, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + var previous *toolFeedbackAnimationState + a.mu.Lock() + if old, ok := a.entries[chatID]; ok { + previous = old + } + a.entries[chatID] = entry + a.mu.Unlock() + + stopToolFeedbackAnimation(previous) + go a.run(chatID, entry) +} + +func (a *ToolFeedbackAnimator) Clear(chatID string) { + if a == nil || strings.TrimSpace(chatID) == "" { + return + } + entry := a.detach(chatID) + stopToolFeedbackAnimation(entry) +} + +func (a *ToolFeedbackAnimator) Take(chatID string) (string, string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", "", false + } + entry := a.detach(chatID) + if entry == nil || strings.TrimSpace(entry.messageID) == "" { + return "", "", false + } + stopToolFeedbackAnimation(entry) + return entry.messageID, entry.baseContent, true +} + +// Update edits an existing tracked feedback message. If the edit fails, the +// previous feedback state is restored so callers can retry without orphaning +// the old progress message. +func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content string) (string, bool, error) { + if a == nil || a.editFn == nil { + return "", false, nil + } + msgID, baseContent, ok := a.Take(chatID) + if !ok { + return "", false, nil + } + + animatedContent := InitialAnimatedToolFeedbackContent(content) + if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil { + a.Record(chatID, msgID, baseContent) + return "", true, err + } + + a.Record(chatID, msgID, content) + return msgID, true, nil +} + +func (a *ToolFeedbackAnimator) StopAll() { + if a == nil { + return + } + a.mu.Lock() + entries := make([]*toolFeedbackAnimationState, 0, len(a.entries)) + for chatID, entry := range a.entries { + entries = append(entries, entry) + delete(a.entries, chatID) + } + a.mu.Unlock() + + for _, entry := range entries { + stopToolFeedbackAnimation(entry) + } +} + +func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState { + if a == nil || strings.TrimSpace(chatID) == "" { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + entry := a.entries[chatID] + delete(a.entries, chatID) + return entry +} + +func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) { + defer close(entry.done) + + ticker := time.NewTicker(toolFeedbackAnimationInterval) + defer ticker.Stop() + + frameIdx := 1 + + for { + select { + case <-entry.stop: + return + case <-ticker.C: + if a.editFn == nil { + continue + } + frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)] + content := formatAnimatedToolFeedbackContent(entry.baseContent, frame) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = a.editFn(ctx, chatID, entry.messageID, content) + cancel() + frameIdx++ + } + } +} + +func InitialAnimatedToolFeedbackContent(baseContent string) string { + return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame) +} + +func formatAnimatedToolFeedbackContent(baseContent, frame string) string { + baseContent = strings.TrimSpace(baseContent) + frame = strings.TrimSpace(frame) + if baseContent == "" { + return "" + } + if frame == "" { + return baseContent + } + lineBreak := strings.IndexByte(baseContent, '\n') + if lineBreak < 0 { + return appendToolFeedbackFrame(baseContent, frame) + } + return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:] +} + +func appendToolFeedbackFrame(firstLine, frame string) string { + firstLine = strings.TrimSpace(firstLine) + frame = strings.TrimSpace(frame) + if firstLine == "" { + return "" + } + if frame == "" { + return firstLine + } + + openTick := strings.IndexByte(firstLine, '`') + if openTick >= 0 { + if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 { + closeTick := openTick + 1 + closeOffset + return firstLine[:closeTick] + frame + firstLine[closeTick:] + } + } + + return firstLine + frame +} + +func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) { + if entry == nil { + return + } + select { + case <-entry.stop: + default: + close(entry.stop) + } + <-entry.done +} diff --git a/pkg/channels/tool_feedback_animator_test.go b/pkg/channels/tool_feedback_animator_test.go new file mode 100644 index 000000000..a23284548 --- /dev/null +++ b/pkg/channels/tool_feedback_animator_test.go @@ -0,0 +1,121 @@ +package channels + +import ( + "context" + "errors" + "testing" +) + +func TestFormatAnimatedToolFeedbackContent(t *testing.T) { + got := formatAnimatedToolFeedbackContent("🔧 `read_file`\nReading config file", "running..") + want := "🔧 `read_filerunning..`\nReading config file" + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestInitialAnimatedToolFeedbackContent(t *testing.T) { + got := InitialAnimatedToolFeedbackContent("🔧 `exec`\nRunning command") + want := "🔧 `exec`\nRunning command" + if got != want { + t.Fatalf("InitialAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestFormatAnimatedToolFeedbackContent_WithoutCodeSpan(t *testing.T) { + got := formatAnimatedToolFeedbackContent("hello", "running..") + want := "hellorunning.." + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() without code span = %q, want %q", got, want) + } +} + +func TestToolFeedbackAnimator_RecordCurrentAndClear(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`") + + msgID, ok := animator.Current("chat-1") + if !ok || msgID != "msg-1" { + t.Fatalf("Current() = (%q, %v), want (msg-1, true)", msgID, ok) + } + + animator.Clear("chat-1") + + msgID, ok = animator.Current("chat-1") + if ok || msgID != "" { + t.Fatalf("Current() after Clear = (%q, %v), want (\"\", false)", msgID, ok) + } +} + +func TestToolFeedbackAnimator_TakeStopsTrackingAndReturnsState(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, baseContent, ok := animator.Take("chat-1") + if !ok { + t.Fatal("Take() = not found, want tracked message") + } + if msgID != "msg-1" { + t.Fatalf("Take() msgID = %q, want msg-1", msgID) + } + if baseContent != "🔧 `read_file`\nChecking config" { + t.Fatalf("Take() baseContent = %q", baseContent) + } + if _, ok := animator.Current("chat-1"); ok { + t.Fatal("expected tracked message to be removed after Take()") + } +} + +func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) { + var animator *ToolFeedbackAnimator + animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error { + if _, ok := animator.Current(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if messageID != "msg-1" { + t.Fatalf("messageID = %q, want msg-1", messageID) + } + if content != "🔧 `write_file`\nUpdating config" { + t.Fatalf("content = %q, want updated animated content", content) + } + return nil + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if !handled { + t.Fatal("Update() handled = false, want true") + } + if msgID != "msg-1" { + t.Fatalf("Update() msgID = %q, want msg-1", msgID) + } +} + +func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) { + editErr := errors.New("edit failed") + animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error { + return editErr + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if !handled { + t.Fatal("Update() handled = false, want true") + } + if !errors.Is(err, editErr) { + t.Fatalf("Update() error = %v, want editErr", err) + } + if msgID != "" { + t.Fatalf("Update() msgID = %q, want empty on failed edit", msgID) + } + if currentID, ok := animator.Current("chat-1"); !ok || currentID != "msg-1" { + t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok) + } +} diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 39e76f752..a7e401bb8 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -11,9 +11,11 @@ func BuiltinDefinitions() []Definition { showCommand(), listCommand(), useCommand(), + btwCommand(), switchCommand(), checkCommand(), clearCommand(), + contextCommand(), subagentsCommand(), reloadCommand(), } diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index 5fd8dd9bc..efd27fa00 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -36,10 +36,10 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { t.Fatalf("/help handler error: %v", err) } // Now uses auto-generated EffectiveUsage which includes agents - if !strings.Contains(reply, "/show [model|channel|agents]") { + if !strings.Contains(reply, "/show [model|channel|agents|mcp ]") { t.Fatalf("/help reply missing /show usage, got %q", reply) } - if !strings.Contains(reply, "/list [models|channels|agents|skills]") { + if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } if !strings.Contains(reply, "/use ") { @@ -174,6 +174,92 @@ func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) { } } +func TestBuiltinListMCP_UsesRuntimeServerStatus(t *testing.T) { + rt := &Runtime{ + ListMCPServers: func(context.Context) []MCPServerInfo { + return []MCPServerInfo{ + {Name: "filesystem", Enabled: true, Deferred: true, Connected: false}, + {Name: "github", Enabled: true, Deferred: false, Connected: true, ToolCount: 3}, + } + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list mcp", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "- `filesystem`\n Enabled: yes\n Deferred: yes\n "+ + "Connected: no\n Active tools: unavailable") { + t.Fatalf("/list mcp reply=%q, want formatted filesystem block", reply) + } + if !strings.Contains(reply, "- `github`\n Enabled: yes\n Deferred: no\n "+ + "Connected: yes\n Active tools: 3") { + t.Fatalf("/list mcp reply=%q, want formatted github block", reply) + } +} + +func TestBuiltinShowMCP_UsesRuntimeToolNames(t *testing.T) { + rt := &Runtime{ + ListMCPTools: func(_ context.Context, serverName string) ([]MCPToolInfo, error) { + if serverName != "github" { + t.Fatalf("serverName=%q, want github", serverName) + } + return []MCPToolInfo{ + { + Name: "create_issue", + Description: "Create a GitHub issue", + Parameters: []MCPToolParameterInfo{ + {Name: "body", Type: "string", Description: "Issue body"}, + {Name: "title", Type: "string", Description: "Issue title", Required: true}, + }, + }, + { + Name: "list_prs", + Description: "List open pull requests", + }, + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/show mcp github", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/show mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "Active MCP tools for `github`:\n- `create_issue`") { + t.Fatalf("/show mcp reply=%q, want tool header", reply) + } + if !strings.Contains(reply, "Description: Create a GitHub issue") { + t.Fatalf("/show mcp reply=%q, want description", reply) + } + if !strings.Contains(reply, " - `title` (string, required): Issue title") { + t.Fatalf("/show mcp reply=%q, want required parameter", reply) + } + if !strings.Contains(reply, " - `body` (string): Issue body") { + t.Fatalf("/show mcp reply=%q, want optional parameter", reply) + } + if !strings.Contains(reply, "- `list_prs`\n Description: List open pull requests\n Parameters: none") { + t.Fatalf("/show mcp reply=%q, want empty parameter block", reply) + } +} + func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) @@ -188,3 +274,79 @@ func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { t.Fatalf("/use command=%q, want=%q", res.Command, "use") } } + +func TestBuiltinBtwCommand_UsesSideQuestionRuntime(t *testing.T) { + rt := &Runtime{ + AskSideQuestion: func(ctx context.Context, question string) (string, error) { + if question != "what is 2+2?" { + t.Fatalf("question=%q, want %q", question, "what is 2+2?") + } + return "4", nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/btw what is 2+2?", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "4" { + t.Fatalf("/btw reply=%q, want=%q", reply, "4") + } +} + +func TestBuiltinBtwCommand_MissingQuestion(t *testing.T) { + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), &Runtime{ + AskSideQuestion: func(context.Context, string) (string, error) { + return "", nil + }, + }) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/btw", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Usage: /btw " { + t.Fatalf("/btw reply=%q, want usage message", reply) + } +} + +func TestBuiltinBtwCommand_PreservesQuestionWhitespace(t *testing.T) { + const want = "explain:\n fmt.Println(\"hi\")" + rt := &Runtime{ + AskSideQuestion: func(ctx context.Context, question string) (string, error) { + if question != want { + t.Fatalf("question=%q, want %q", question, want) + } + return "ok", nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + res := ex.Execute(context.Background(), Request{ + Text: "/btw " + want, + Reply: func(text string) error { + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } +} diff --git a/pkg/commands/cmd_btw.go b/pkg/commands/cmd_btw.go new file mode 100644 index 000000000..509f2a80c --- /dev/null +++ b/pkg/commands/cmd_btw.go @@ -0,0 +1,51 @@ +package commands + +import ( + "context" + "strings" +) + +func btwCommand() Definition { + return Definition{ + Name: "btw", + Description: "Ask a side question without changing session history", + Usage: "/btw ", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + const emptyAnswerMsg = "The model returned an empty response. This may indicate a provider error or token limit." + + if rt == nil || rt.AskSideQuestion == nil { + return req.Reply(unavailableMsg) + } + + question := sideQuestionText(req.Text) + if question == "" { + return req.Reply("Usage: /btw ") + } + + answer, err := rt.AskSideQuestion(ctx, question) + if err != nil { + return req.Reply(err.Error()) + } + if strings.TrimSpace(answer) == "" { + return req.Reply(emptyAnswerMsg) + } + + return req.Reply(answer) + }, + } +} + +func sideQuestionText(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return "" + } + parts := strings.Fields(input) + if len(parts) < 2 { + return "" + } + if !strings.HasPrefix(input, parts[0]) { + return "" + } + return strings.TrimSpace(input[len(parts[0]):]) +} diff --git a/pkg/commands/cmd_context.go b/pkg/commands/cmd_context.go new file mode 100644 index 000000000..55481662c --- /dev/null +++ b/pkg/commands/cmd_context.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + "fmt" +) + +func contextCommand() Definition { + return Definition{ + Name: "context", + Description: "Show current session context and token usage", + Usage: "/context", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetContextStats == nil { + return req.Reply(unavailableMsg) + } + stats := rt.GetContextStats() + if stats == nil { + return req.Reply("No active session context.") + } + return req.Reply(formatContextStats(stats)) + }, + } +} + +func formatContextStats(s *ContextStats) string { + remaining := s.CompressAtTokens - s.UsedTokens + if remaining < 0 { + remaining = 0 + } + usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1) + return fmt.Sprintf( + "Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens", + s.MessageCount, + s.UsedTokens, + s.TotalTokens, + usedWindowPercent, + s.CompressAtTokens, + s.UsedPercent, + remaining, + ) +} diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go index 7186a6c25..c0021e55c 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -64,6 +64,11 @@ func listCommand() Definition { )) }, }, + { + Name: "mcp", + Description: "Configured MCP servers", + Handler: listMCPServersHandler(), + }, }, } } diff --git a/pkg/commands/cmd_show.go b/pkg/commands/cmd_show.go index c655e6880..cda7aaea7 100644 --- a/pkg/commands/cmd_show.go +++ b/pkg/commands/cmd_show.go @@ -33,6 +33,12 @@ func showCommand() Definition { Description: "Registered agents", Handler: agentsHandler(), }, + { + Name: "mcp", + Description: "Active tools for an MCP server", + ArgsUsage: "", + Handler: showMCPToolsHandler(), + }, }, } } diff --git a/pkg/commands/handler_mcp.go b/pkg/commands/handler_mcp.go new file mode 100644 index 000000000..c3dcc1147 --- /dev/null +++ b/pkg/commands/handler_mcp.go @@ -0,0 +1,106 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func listMCPServersHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPServers == nil { + return req.Reply(unavailableMsg) + } + + servers := rt.ListMCPServers(ctx) + if len(servers) == 0 { + return req.Reply("No MCP servers configured") + } + + header := "Configured MCP Servers:" + if rt.Config != nil && !rt.Config.Tools.IsToolEnabled("mcp") { + header = "Configured MCP Servers (integration disabled):" + } + + lines := make([]string, 0, len(servers)*5+1) + lines = append(lines, header) + for idx, server := range servers { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", server.Name)) + lines = append(lines, fmt.Sprintf(" Enabled: %s", yesNo(server.Enabled))) + lines = append(lines, fmt.Sprintf(" Deferred: %s", yesNo(server.Deferred))) + lines = append(lines, fmt.Sprintf(" Connected: %s", yesNo(server.Connected))) + if server.Connected { + lines = append(lines, fmt.Sprintf(" Active tools: %d", server.ToolCount)) + continue + } + lines = append(lines, " Active tools: unavailable") + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func showMCPToolsHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPTools == nil { + return req.Reply(unavailableMsg) + } + + serverName := nthToken(req.Text, 2) + if serverName == "" { + return req.Reply("Usage: /show mcp ") + } + + tools, err := rt.ListMCPTools(ctx, serverName) + if err != nil { + return req.Reply(err.Error()) + } + if len(tools) == 0 { + return req.Reply(fmt.Sprintf("MCP server '%s' has no active tools", serverName)) + } + + lines := make([]string, 0, len(tools)*6+1) + lines = append(lines, fmt.Sprintf("Active MCP tools for `%s`:", serverName)) + for idx, tool := range tools { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", tool.Name)) + lines = append(lines, fmt.Sprintf(" Description: %s", tool.Description)) + if len(tool.Parameters) == 0 { + lines = append(lines, " Parameters: none") + continue + } + + lines = append(lines, " Parameters:") + for _, param := range tool.Parameters { + line := fmt.Sprintf(" - `%s`", param.Name) + if param.Type != "" { + line += fmt.Sprintf(" (%s", param.Type) + if param.Required { + line += ", required" + } + line += ")" + } else if param.Required { + line += " (required)" + } + if param.Description != "" { + line += ": " + param.Description + } + lines = append(lines, line) + } + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func yesNo(v bool) string { + if v { + return "yes" + } + return "no" +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 5ba6a1bd2..c17b7cf1c 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -1,6 +1,40 @@ package commands -import "github.com/sipeed/picoclaw/pkg/config" +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type MCPServerInfo struct { + Name string + Enabled bool + Deferred bool + Connected bool + ToolCount int +} + +type MCPToolParameterInfo struct { + Name string + Type string + Description string + Required bool +} + +type MCPToolInfo struct { + Name string + Description string + Parameters []MCPToolParameterInfo +} + +// ContextStats describes current session context window usage. +type ContextStats struct { + UsedTokens int + TotalTokens int // model context window + CompressAtTokens int // compression threshold + UsedPercent int // 0-100 + MessageCount int +} // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) @@ -8,11 +42,15 @@ import "github.com/sipeed/picoclaw/pkg/config" type Runtime struct { Config *config.Config GetModelInfo func() (name, provider string) + AskSideQuestion func(ctx context.Context, question string) (string, error) ListAgentIDs func() []string ListDefinitions func() []Definition ListSkillNames func() []string + ListMCPServers func(ctx context.Context) []MCPServerInfo + ListMCPTools func(ctx context.Context, serverName string) ([]MCPToolInfo, error) GetEnabledChannels func() []string GetActiveTurn func() any // Returning any to avoid circular dependency with agent package + GetContextStats func() *ContextStats SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error ClearHistory func() error diff --git a/pkg/config/config.go b/pkg/config/config.go index ab631107d..16497b4ac 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -247,8 +247,9 @@ type SubTurnConfig struct { } type ToolFeedbackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` - MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` + Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` + MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` + SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"` } type AgentDefaults struct { @@ -268,7 +269,8 @@ type AgentDefaults struct { SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"` - SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" + SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" + MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential) SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker @@ -285,7 +287,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } -// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. +// GetToolFeedbackMaxArgsLength returns the max visible text length for tool argument previews. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength @@ -298,6 +300,13 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool { return d.ToolFeedback.Enabled } +// IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback +// update should be sent as its own chat message instead of editing a single +// in-place progress message. +func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool { + return d.ToolFeedback.SeparateMessages +} + // GetModelName returns the effective model name for the agent defaults. // It prefers the new "model_name" field but falls back to "model" for backward compatibility. func (d *AgentDefaults) GetModelName() string { @@ -522,15 +531,16 @@ type VoiceConfig struct { // ModelConfig represents a model-centric provider configuration. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only. -// The model field uses protocol prefix format: [protocol/]model-identifier -// Supported protocols include openai, anthropic, antigravity, claude-cli, +// The Model field may be either a plain model identifier or a provider-prefixed +// identifier such as "openai/gpt-5.4" or "nvidia/z-ai/glm-5.1". +// Supported providers include openai, anthropic, antigravity, claude-cli, // codex-cli, github-copilot, and named OpenAI-compatible protocols such as // groq, deepseek, modelscope, and novita. -// Default protocol is "openai" if no prefix is specified. type ModelConfig struct { // Required fields ModelName string `json:"model_name"` // User-facing alias for the model - Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") + Provider string `json:"provider"` // Provider name for routing and selection. When empty, provider resolution infers it from Model. + Model string `json:"model"` // Model identifier, optionally provider-prefixed. // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL @@ -986,7 +996,9 @@ func LoadConfig(path string) (*Config, error) { Version int `json:"version"` } if e := json.Unmarshal(data, &versionInfo); e != nil { - return nil, fmt.Errorf("failed to detect config version: %w", e) + e = wrapJSONError(data, e, "config.json") + logger.ErrorCF("config", formatDiagnosticLogMessage("Malformed config file", e), map[string]any{"path": path}) + return nil, e } if len(data) <= 10 { logger.Warn(fmt.Sprintf("content is [%s]", string(data))) @@ -1001,10 +1013,23 @@ func LoadConfig(path string) (*Config, error) { "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } var m map[string]any m, err = loadConfigMap(path) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } @@ -1046,10 +1071,23 @@ func LoadConfig(path string) (*Config, error) { "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } var m map[string]any m, err = loadConfigMap(path) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } @@ -1091,9 +1129,22 @@ func LoadConfig(path string) (*Config, error) { "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } var m map[string]any m, err = loadConfigMap(path) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } migrateErr := migrateV2ToV3(m) @@ -1128,6 +1179,11 @@ func LoadConfig(path string) (*Config, error) { // Current version cfg, err = loadConfig(data) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } // Load security configuration @@ -1410,6 +1466,7 @@ 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]), @@ -1433,6 +1490,7 @@ 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, diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go index 6eaf32bc1..65cfeb107 100644 --- a/pkg/config/config_struct.go +++ b/pkg/config/config_struct.go @@ -22,6 +22,11 @@ import ( type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + // Accept a single JSON string for convenience, e.g.: // "text": "Thinking..." var singleString string diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d9ca0cb9d..d455572eb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -787,6 +787,9 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) { if cfg.Agents.Defaults.ToolFeedback.Enabled { t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false") } + if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { + t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false") + } } func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { @@ -807,6 +810,9 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { if cfg.Agents.Defaults.ToolFeedback.Enabled { t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file") } + if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { + t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file") + } } func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { @@ -841,6 +847,72 @@ func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) { } } +func TestLoadConfig_SyntaxErrorReportsLineAndColumn(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"enabled\": true,,\n \"format\": \"markdown\"\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected syntax error, got nil") + } + if !strings.Contains(err.Error(), "syntax error at line 5, column 23") { + t.Fatalf("expected line/column diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "\"enabled\": true,,") { + t.Fatalf("expected source snippet in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "^") { + t.Fatalf("expected caret marker in diagnostic, got %q", err.Error()) + } +} + +func TestLoadConfig_TypeErrorReportsFieldPath(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"fetch_limit_bytes\": \"oops\"\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected type error, got nil") + } + if !strings.Contains(err.Error(), "type error at line 5, column 33") { + t.Fatalf("expected line/column diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "fetch_limit_bytes") { + t.Fatalf("expected field name in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "\"fetch_limit_bytes\": \"oops\"") { + t.Fatalf("expected source snippet in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "^") { + t.Fatalf("expected caret marker in diagnostic, got %q", err.Error()) + } +} + +func TestLoadConfig_UnknownFieldsReportsExactPaths(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"weeb\": {\n \"enabled\": true\n },\n \"web\": {\n \"fatch_limit_bytes\": 123\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected unknown field error, got nil") + } + if !strings.Contains(err.Error(), "tools.weeb") || !strings.Contains(err.Error(), "tools.web.fatch_limit_bytes") { + t.Fatalf("expected exact unknown field paths, got %q", err.Error()) + } +} + func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Exec.AllowRemote { @@ -1258,6 +1330,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { input string expected []string }{ + { + name: "null", + input: `null`, + expected: nil, + }, { name: "single string", input: `"Thinking..."`, @@ -1286,6 +1363,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { if err := json.Unmarshal([]byte(tt.input), &f); err != nil { t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err) } + if tt.expected == nil { + if f != nil { + t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f) + } + return + } if len(f) != len(tt.expected) { t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected)) } @@ -1338,25 +1421,12 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) { } // TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext -// api_key into memory but does NOT rewrite the config file. File writes are the sole +// api_keys entry into memory but does NOT rewrite the config file. File writes are the sole // responsibility of SaveConfig. func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") - const original = `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` - if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } - secPath := filepath.Join(dir, SecurityConfigFile) - const securityConfig = ` -model_list: - test:0: - api_keys: - - "sk-plaintext" -` - if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } + const original = `{"version":2,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_keys":["sk-plaintext"]}]}` if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { t.Fatalf("setup: %v", err) } @@ -1933,7 +2003,7 @@ func TestDefaultConfig_MinimaxExtraBody(t *testing.T) { var minimaxCfg *ModelConfig for i := range cfg.ModelList { - if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" { + if cfg.ModelList[i].Provider == "minimax" && cfg.ModelList[i].Model == "MiniMax-M2.5" { minimaxCfg = cfg.ModelList[i] break } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index f2f5c44c7..f3aaca7ab 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -35,8 +35,9 @@ func DefaultConfig() *Config { SummarizeTokenPercent: 75, SteeringMode: "one-at-a-time", ToolFeedback: ToolFeedbackConfig{ - Enabled: false, - MaxArgsLength: 300, + Enabled: false, + MaxArgsLength: 300, + SeparateMessages: false, }, SplitOnMarker: false, }, @@ -61,129 +62,148 @@ func DefaultConfig() *Config { // Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys { ModelName: "glm-4.7", - Model: "zhipu/glm-4.7", + Provider: "zhipu", + Model: "glm-4.7", APIBase: "https://open.bigmodel.cn/api/paas/v4", }, // OpenAI - https://platform.openai.com/api-keys { ModelName: "gpt-5.4", - Model: "openai/gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", APIBase: "https://api.openai.com/v1", }, // Anthropic Claude - https://console.anthropic.com/settings/keys { ModelName: "claude-sonnet-4.6", - Model: "anthropic/claude-sonnet-4.6", + Provider: "anthropic", + Model: "claude-sonnet-4.6", APIBase: "https://api.anthropic.com/v1", }, // DeepSeek - https://platform.deepseek.com/ { ModelName: "deepseek-chat", - Model: "deepseek/deepseek-chat", + Provider: "deepseek", + Model: "deepseek-chat", APIBase: "https://api.deepseek.com/v1", }, // Venice AI - https://venice.ai { ModelName: "venice-uncensored", - Model: "venice/venice-uncensored", + Provider: "venice", + Model: "venice-uncensored", APIBase: "https://api.venice.ai/api/v1", }, // Google Gemini - https://ai.google.dev/ { ModelName: "gemini-2.0-flash", - Model: "gemini/gemini-2.0-flash-exp", + Provider: "gemini", + Model: "gemini-2.0-flash-exp", APIBase: "https://generativelanguage.googleapis.com/v1beta", }, // Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey { ModelName: "qwen-plus", - Model: "qwen/qwen-plus", + Provider: "qwen", + Model: "qwen-plus", APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1", }, // Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys { ModelName: "moonshot-v1-8k", - Model: "moonshot/moonshot-v1-8k", + Provider: "moonshot", + Model: "moonshot-v1-8k", APIBase: "https://api.moonshot.cn/v1", }, // Groq - https://console.groq.com/keys { ModelName: "llama-3.3-70b", - Model: "groq/llama-3.3-70b-versatile", + Provider: "groq", + Model: "llama-3.3-70b-versatile", APIBase: "https://api.groq.com/openai/v1", }, // OpenRouter (100+ models) - https://openrouter.ai/keys { ModelName: "openrouter-auto", - Model: "openrouter/auto", + Provider: "openrouter", + Model: "auto", APIBase: "https://openrouter.ai/api/v1", }, { ModelName: "openrouter-gpt-5.4", - Model: "openrouter/openai/gpt-5.4", + Provider: "openrouter", + Model: "openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", }, // NVIDIA - https://build.nvidia.com/ { ModelName: "nemotron-4-340b", - Model: "nvidia/nemotron-4-340b-instruct", + Provider: "nvidia", + Model: "nemotron-4-340b-instruct", APIBase: "https://integrate.api.nvidia.com/v1", }, // Cerebras - https://inference.cerebras.ai/ { ModelName: "cerebras-llama-3.3-70b", - Model: "cerebras/llama-3.3-70b", + Provider: "cerebras", + Model: "llama-3.3-70b", APIBase: "https://api.cerebras.ai/v1", }, // Vivgrid - https://vivgrid.com { ModelName: "vivgrid-auto", - Model: "vivgrid/auto", + Provider: "vivgrid", + Model: "auto", APIBase: "https://api.vivgrid.com/v1", }, // Volcengine (火山引擎) - https://console.volcengine.com/ark { ModelName: "ark-code-latest", - Model: "volcengine/ark-code-latest", + Provider: "volcengine", + Model: "ark-code-latest", APIBase: "https://ark.cn-beijing.volces.com/api/v3", }, { ModelName: "doubao-pro", - Model: "volcengine/doubao-pro-32k", + Provider: "volcengine", + Model: "doubao-pro-32k", APIBase: "https://ark.cn-beijing.volces.com/api/v3", }, // ShengsuanYun (神算云) { ModelName: "deepseek-v3", - Model: "shengsuanyun/deepseek-v3", + Provider: "shengsuanyun", + Model: "deepseek-v3", APIBase: "https://api.shengsuanyun.com/v1", }, // Antigravity (Google Cloud Code Assist) - OAuth only { ModelName: "gemini-flash", - Model: "antigravity/gemini-3-flash", + Provider: "antigravity", + Model: "gemini-3-flash", AuthMethod: "oauth", }, // GitHub Copilot - https://github.com/settings/tokens { ModelName: "copilot-gpt-5.4", - Model: "github-copilot/gpt-5.4", + Provider: "github-copilot", + Model: "gpt-5.4", APIBase: "http://localhost:4321", AuthMethod: "oauth", }, @@ -191,33 +211,38 @@ func DefaultConfig() *Config { // Ollama (local) - https://ollama.com { ModelName: "llama3", - Model: "ollama/llama3", + Provider: "ollama", + Model: "llama3", APIBase: "http://localhost:11434/v1", }, // Mistral AI - https://console.mistral.ai/api-keys { ModelName: "mistral-small", - Model: "mistral/mistral-small-latest", + Provider: "mistral", + Model: "mistral-small-latest", APIBase: "https://api.mistral.ai/v1", }, // Avian - https://avian.io { ModelName: "deepseek-v3.2", - Model: "avian/deepseek/deepseek-v3.2", + Provider: "avian", + Model: "deepseek/deepseek-v3.2", APIBase: "https://api.avian.io/v1", }, { ModelName: "kimi-k2.5", - Model: "avian/moonshotai/kimi-k2.5", + Provider: "avian", + Model: "moonshotai/kimi-k2.5", APIBase: "https://api.avian.io/v1", }, // Minimax - https://api.minimaxi.com/ { ModelName: "MiniMax-M2.5", - Model: "minimax/MiniMax-M2.5", + Provider: "minimax", + Model: "MiniMax-M2.5", APIBase: "https://api.minimaxi.com/v1", ExtraBody: map[string]any{"reasoning_split": true}, }, @@ -225,28 +250,32 @@ func DefaultConfig() *Config { // LongCat - https://longcat.chat/platform { ModelName: "LongCat-Flash-Thinking", - Model: "longcat/LongCat-Flash-Thinking", + Provider: "longcat", + Model: "LongCat-Flash-Thinking", APIBase: "https://api.longcat.chat/openai", }, // ModelScope (魔搭社区) - https://modelscope.cn/my/tokens { ModelName: "modelscope-qwen", - Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507", + Provider: "modelscope", + Model: "Qwen/Qwen3-235B-A22B-Instruct-2507", APIBase: "https://api-inference.modelscope.cn/v1", }, // VLLM (local) - http://localhost:8000 { ModelName: "local-model", - Model: "vllm/custom-model", + Provider: "vllm", + Model: "custom-model", APIBase: "http://localhost:8000/v1", }, // LM Studio (local) - http://localhost:1234 { ModelName: "lmstudio-local", - Model: "lmstudio/openai/gpt-oss-20b", + Provider: "lmstudio", + Model: "openai/gpt-oss-20b", APIBase: "http://localhost:1234/v1", }, @@ -254,7 +283,8 @@ func DefaultConfig() *Config { // model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name { ModelName: "azure-gpt5", - Model: "azure/my-gpt5-deployment", + Provider: "azure", + Model: "my-gpt5-deployment", APIBase: "https://your-resource.openai.azure.com", }, }, @@ -514,6 +544,14 @@ func defaultChannels() ChannelsConfig { "max_connections": 100, }, }, + "irc": map[string]any{ + "settings": map[string]any{ + "server": "", + "tls": true, + "nick": "picoclaw", + "channels": []string{}, + }, + }, } channels := make(ChannelsConfig, len(defs)) diff --git a/pkg/config/diagnostics.go b/pkg/config/diagnostics.go new file mode 100644 index 000000000..bbc59c03b --- /dev/null +++ b/pkg/config/diagnostics.go @@ -0,0 +1,441 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strings" + "unicode/utf8" + + "golang.org/x/term" +) + +func decodeJSONWithDiagnostics(data []byte, target any, label string) error { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return wrapJSONError(data, err, label) + } + + unknownFields := collectUnknownJSONFields(raw, reflect.TypeOf(target), "") + if len(unknownFields) > 0 { + sort.Strings(unknownFields) + return fmt.Errorf( + "%s contains unknown field(s): %s", + label, + strings.Join(unknownFields, ", "), + ) + } + + if err := json.Unmarshal(data, target); err != nil { + return wrapJSONError(data, err, label) + } + return nil +} + +func DiagnosticSummary(err error) string { + if err == nil { + return "" + } + summary, _ := splitDiagnosticError(err.Error()) + return stripANSISequences(summary) +} + +func formatDiagnosticLogMessage(prefix string, err error) string { + if err == nil { + return prefix + } + + summary, preview := splitDiagnosticError(err.Error()) + summary = stripANSISequences(summary) + if preview == "" { + if summary == "" { + return prefix + } + return prefix + ": " + summary + } + if summary == "" { + return prefix + "\n" + preview + } + return prefix + ": " + summary + "\n" + preview +} + +func wrapJSONError(data []byte, err error, label string) error { + switch e := err.(type) { + case *json.SyntaxError: + line, column := lineAndColumnForOffset(data, e.Offset) + preview := diagnosticPreviewForOffset(data, e.Offset) + if preview != "" { + return fmt.Errorf( + "%s syntax error at line %d, column %d: %w\n%s", + label, + line, + column, + err, + preview, + ) + } + return fmt.Errorf("%s syntax error at line %d, column %d: %w", label, line, column, err) + case *json.UnmarshalTypeError: + line, column := lineAndColumnForOffset(data, e.Offset) + preview := diagnosticPreviewForOffset(data, e.Offset) + field := strings.TrimSpace(e.Field) + if field != "" { + if preview != "" { + return fmt.Errorf( + "%s type error at line %d, column %d for field %q: expected %s but got %s\n%s", + label, + line, + column, + field, + e.Type.String(), + e.Value, + preview, + ) + } + return fmt.Errorf( + "%s type error at line %d, column %d for field %q: expected %s but got %s", + label, + line, + column, + field, + e.Type.String(), + e.Value, + ) + } + if preview != "" { + return fmt.Errorf( + "%s type error at line %d, column %d: expected %s but got %s\n%s", + label, + line, + column, + e.Type.String(), + e.Value, + preview, + ) + } + return fmt.Errorf( + "%s type error at line %d, column %d: expected %s but got %s", + label, + line, + column, + e.Type.String(), + e.Value, + ) + default: + return fmt.Errorf("failed to parse %s: %w", label, err) + } +} + +func splitDiagnosticError(message string) (string, string) { + if idx := strings.IndexByte(message, '\n'); idx >= 0 { + return message[:idx], message[idx+1:] + } + return message, "" +} + +func stripANSISequences(s string) string { + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s)) + + for i := 0; i < len(s); i++ { + if s[i] != 0x1b { + b.WriteByte(s[i]) + continue + } + if i+1 >= len(s) || s[i+1] != '[' { + continue + } + i += 2 + for i < len(s) { + c := s[i] + if c >= '@' && c <= '~' { + break + } + i++ + } + } + + return b.String() +} + +func diagnosticPreviewForOffset(data []byte, offset int64) string { + if len(data) == 0 { + return "" + } + + start, end := lineBoundsForOffset(data, offset) + if start >= end { + return "" + } + + lineNumber, column := lineAndColumnForOffset(data, offset) + line := strings.TrimRight(string(data[start:end]), "\r\n") + if strings.TrimSpace(line) == "" { + return "" + } + + trimmedLine, trimOffset := trimDiagnosticLine(line, column) + if trimmedLine == "" { + return "" + } + + prefix := fmt.Sprintf("%4d | ", lineNumber) + caretColumn := column - trimOffset + if caretColumn < 1 { + caretColumn = 1 + } + + if diagnosticsUseColor() { + linePrefix := "\x1b[2m" + prefix + "\x1b[0m" + caretPrefix := "\x1b[2m" + strings.Repeat(" ", len(fmt.Sprintf("%4d", lineNumber))) + " | " + "\x1b[0m" + highlighted := highlightDiagnosticColumn(trimmedLine, caretColumn) + caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1)) + return fmt.Sprintf( + " %s%s\n %s%s\x1b[1;31m^\x1b[0m", + linePrefix, + highlighted, + caretPrefix, + caretPad, + ) + } + + caretPrefix := strings.Repeat(" ", len(prefix)) + caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1)) + return fmt.Sprintf( + " %s%s\n %s%s^", + prefix, + trimmedLine, + caretPrefix, + caretPad, + ) +} + +func lineAndColumnForOffset(data []byte, offset int64) (int, int) { + if offset <= 0 { + return 1, 1 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + + line := 1 + column := 1 + for i := int64(0); i < offset-1; i++ { + if data[i] == '\n' { + line++ + column = 1 + continue + } + column++ + } + return line, column +} + +func lineBoundsForOffset(data []byte, offset int64) (int, int) { + if len(data) == 0 { + return 0, 0 + } + + if offset <= 0 { + offset = 1 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + + index := int(offset - 1) + if index < 0 { + index = 0 + } + if index >= len(data) { + index = len(data) - 1 + } + + start := index + for start > 0 && data[start-1] != '\n' { + start-- + } + + end := index + for end < len(data) && data[end] != '\n' { + end++ + } + + return start, end +} + +func trimDiagnosticLine(line string, column int) (string, int) { + runes := []rune(line) + if len(runes) == 0 { + return "", 0 + } + + if len(runes) <= 160 { + return line, 0 + } + + const contextBefore = 60 + const maxWidth = 160 + + start := column - 1 - contextBefore + if start < 0 { + start = 0 + } + if start > len(runes)-maxWidth { + start = len(runes) - maxWidth + } + if start < 0 { + start = 0 + } + + end := start + maxWidth + if end > len(runes) { + end = len(runes) + } + + trimmed := string(runes[start:end]) + trimOffset := start + + if start > 0 { + trimmed = "..." + trimmed + trimOffset -= 3 + } + if end < len(runes) { + trimmed += "..." + } + + return trimmed, trimOffset +} + +func diagnosticsUseColor() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +func highlightDiagnosticColumn(line string, column int) string { + runes := []rune(line) + if column < 1 || column > len(runes) { + return line + } + + index := column - 1 + return string(runes[:index]) + "\x1b[31m" + string(runes[index]) + "\x1b[0m" + string(runes[index+1:]) +} + +func maxRuneCount(s string, count int) int { + if count <= 0 { + return 0 + } + runes := []rune(s) + if count > len(runes) { + count = len(runes) + } + return utf8.RuneCountInString(string(runes[:count])) +} + +func collectUnknownJSONFields(raw any, targetType reflect.Type, path string) []string { + targetType = derefType(targetType) + if targetType == nil { + return nil + } + + switch targetType.Kind() { + case reflect.Struct: + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + fieldMap := jsonFieldTypeMap(targetType) + var issues []string + for key, value := range obj { + fieldType, exists := fieldMap[key] + fieldPath := appendJSONPath(path, key) + if !exists { + issues = append(issues, fieldPath) + continue + } + issues = append(issues, collectUnknownJSONFields(value, fieldType, fieldPath)...) + } + return issues + case reflect.Slice, reflect.Array: + items, ok := raw.([]any) + if !ok { + return nil + } + var issues []string + elemType := targetType.Elem() + for i, item := range items { + itemPath := fmt.Sprintf("%s[%d]", path, i) + issues = append(issues, collectUnknownJSONFields(item, elemType, itemPath)...) + } + return issues + case reflect.Map: + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + var issues []string + elemType := targetType.Elem() + for key, value := range obj { + fieldPath := appendJSONPath(path, key) + issues = append(issues, collectUnknownJSONFields(value, elemType, fieldPath)...) + } + return issues + default: + return nil + } +} + +func jsonFieldTypeMap(t reflect.Type) map[string]reflect.Type { + result := make(map[string]reflect.Type) + populateJSONFieldTypeMap(result, derefType(t)) + return result +} + +func populateJSONFieldTypeMap(result map[string]reflect.Type, t reflect.Type) { + if t == nil || t.Kind() != reflect.Struct { + return + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + tag := field.Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "-" { + continue + } + + if field.Anonymous && name == "" { + populateJSONFieldTypeMap(result, derefType(field.Type)) + continue + } + + if name == "" { + name = field.Name + } + result[name] = field.Type + } +} + +func derefType(t reflect.Type) reflect.Type { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +func appendJSONPath(path, segment string) string { + if path == "" { + return segment + } + return path + "." + segment +} diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 4fe2148b2..96914819e 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -27,6 +27,59 @@ func buildModelWithProtocol(protocol, model string) string { return protocol + "/" + model } +type legacyDiagnosticConfig struct { + Version int `json:"version"` + Isolation IsolationConfig `json:"isolation,omitempty"` + Agents legacyDiagnosticAgents `json:"agents,omitempty"` + Session SessionConfig `json:"session,omitempty"` + Channels map[string]any `json:"channels,omitempty"` + ChannelList ChannelsConfig `json:"channel_list,omitempty"` + ModelList []map[string]any `json:"model_list,omitempty"` + Gateway GatewayConfig `json:"gateway,omitempty"` + Hooks HooksConfig `json:"hooks,omitempty"` + Tools ToolsConfig `json:"tools,omitempty"` + Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"` + Devices DevicesConfig `json:"devices,omitempty"` + Voice VoiceConfig `json:"voice,omitempty"` + Bindings json.RawMessage `json:"bindings,omitempty"` + Providers json.RawMessage `json:"providers,omitempty"` +} + +type legacyDiagnosticAgents struct { + Defaults legacyDiagnosticAgentDefaults `json:"defaults,omitempty"` + List []AgentConfig `json:"list,omitempty"` + Dispatch *DispatchConfig `json:"dispatch,omitempty"` +} + +type legacyDiagnosticAgentDefaults struct { + AgentDefaults + LegacyModel string `json:"model,omitempty"` +} + +func validateLegacyConfigDiagnostics(data []byte) error { + var cfg legacyDiagnosticConfig + return decodeJSONWithDiagnostics(data, &cfg, "config.json") +} + +func migrateLegacyAgentDefaultsModel(m map[string]any) { + agents, ok := m["agents"].(map[string]any) + if !ok { + return + } + defaults, ok := agents["defaults"].(map[string]any) + if !ok { + return + } + model, hasModel := defaults["model"] + if !hasModel { + return + } + if _, hasModelName := defaults["model_name"]; !hasModelName { + defaults["model_name"] = model + } + delete(defaults, "model") +} + // loadConfigV1 loads a version 1 config (current schema) func loadConfig(data []byte) (*Config, error) { cfg := DefaultConfig() @@ -38,14 +91,14 @@ func loadConfig(data []byte) (*Config, error) { // index position. We only reset cfg.ModelList when the user actually provides // entries; when count is 0 we keep DefaultConfig's built-in list as fallback. var tmp Config - if err := json.Unmarshal(data, &tmp); err != nil { + if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil { return nil, err } if len(tmp.ModelList) > 0 { cfg.ModelList = nil } - if err := json.Unmarshal(data, cfg); err != nil { + if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil { return nil, err } return cfg, nil @@ -96,17 +149,7 @@ func migrateV0ToV1(m map[string]any) error { return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"]) } - // Migrate agents.defaults.model → agents.defaults.model_name - if agents, ok := m["agents"].(map[string]any); ok { - if defaults, ok := agents["defaults"].(map[string]any); ok { - if model, hasModel := defaults["model"]; hasModel { - if _, hasModelName := defaults["model_name"]; !hasModelName { - defaults["model_name"] = model - } - delete(defaults, "model") - } - } - } + migrateLegacyAgentDefaultsModel(m) // Migrate legacy providers to model_list if no model_list exists if _, hasModelList := m["model_list"]; !hasModelList { @@ -275,6 +318,9 @@ func migrateV2ToV3(m map[string]any) error { return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"]) } + migrateLegacyAgentDefaultsModel(m) + delete(m, "bindings") + // Rename channels → channel_list if channels, ok := m["channels"]; ok { delete(m, "channels") @@ -334,7 +380,7 @@ func loadConfigMap(path string) (map[string]any, error) { return nil, fmt.Errorf("failed to read config: %w", err) } if err = json.Unmarshal(data, &m1); err != nil { - return nil, fmt.Errorf("failed to parse config: %w", err) + return nil, wrapJSONError(data, err, "config.json") } secPath := securityPath(path) data, err = os.ReadFile(secPath) diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index 947e942da..cb55db938 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -188,6 +188,7 @@ 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", @@ -206,6 +207,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { if primary.APIBase != "https://api.example.com" { t.Errorf("expected api_base preserved, got %q", primary.APIBase) } + if primary.Provider != "openrouter" { + t.Errorf("expected provider preserved, got %q", primary.Provider) + } if primary.Proxy != "http://proxy:8080" { t.Errorf("expected proxy preserved, got %q", primary.Proxy) } @@ -224,6 +228,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { // Check additional entry also preserves fields additional := result[0] + if additional.Provider != "openrouter" { + t.Errorf("expected additional provider preserved, got %q", additional.Provider) + } if additional.APIBase != "https://api.example.com" { t.Errorf("expected additional api_base preserved, got %q", additional.APIBase) } diff --git a/pkg/config/security.go b/pkg/config/security.go index c5d3bf507..9f0d1339c 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -75,7 +75,7 @@ func loadSecurityConfig(cfg *Config, securityPath string) error { // Unmarshal non-channel fields from security.yml // This will resolve encrypted values for model_list, tools, etc. if err := yaml.Unmarshal(data, cfg); err != nil { - return fmt.Errorf("failed to parse security config: %w", err) + return fmt.Errorf("failed to parse security config %s: %w", securityPath, err) } if err := applyLegacySkillsSecurityConfig(cfg, data); err != nil { return fmt.Errorf("failed to parse legacy skills security config: %w", err) diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 5fe7b6b97..8fc2f167c 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -43,11 +43,10 @@ func TestSecurityConfigIntegration(t *testing.T) { t.Run("Full workflow with security references", func(t *testing.T) { tmpDir := t.TempDir() - // Create config.json with direct security values (not ref: references) - // These values should take precedence over .security.yml + // Create config.json with direct security values using the current schema. configPath := filepath.Join(tmpDir, "config.json") configContent := `{ - "version": 1, + "version": 2, "model_list": [ { "model_name": "test-model", diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 039f45075..f58590d5b 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -9,7 +9,6 @@ import ( "path/filepath" "sort" "strconv" - "strings" "sync" "sync/atomic" "syscall" @@ -27,7 +26,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" - "github.com/sipeed/picoclaw/pkg/channels/pico" + _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/teams_webhook" @@ -316,8 +315,6 @@ func executeReload( ) error { defer runningServices.reloading.Store(false) - overridePicoToken(newCfg, runningServices.authToken) - return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) } @@ -386,8 +383,6 @@ func setupAndStartServices( fms.Start() } - overridePicoToken(cfg, authToken) - runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { @@ -788,23 +783,6 @@ func setupCronTool( return cronService, nil } -// overridePicoToken replaces the pico channel token with the one from the PID file. -// The PID file is the single source of truth for the pico auth token; -// it is generated once at gateway startup and remains unchanged across reloads. -func overridePicoToken(cfg *config.Config, token string) { - picoBC := cfg.Channels.GetByType(config.ChannelPico) - if picoBC == nil || !picoBC.Enabled { - return - } - var picoCfg config.PicoSettings - picoBC.Decode(&picoCfg) - picoToken := picoCfg.Token.String() - if picoToken == "" || strings.HasPrefix(picoToken, pico.PicoTokenPrefix) { - return - } - picoCfg.SetToken(pico.PicoTokenPrefix + token + picoToken) -} - func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { return func(prompt, channel, chatID string) *tools.ToolResult { if channel == "" || chatID == "" { diff --git a/pkg/isolation/README_CN.md b/pkg/isolation/README.zh.md similarity index 100% rename from pkg/isolation/README_CN.md rename to pkg/isolation/README.zh.md diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go index 9434976f7..1b3be8bd3 100644 --- a/pkg/isolation/platform_windows.go +++ b/pkg/isolation/platform_windows.go @@ -76,7 +76,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE - if _, err := windows.SetInformationJobObject( + if _, err = windows.SetInformationJobObject( job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&info)), @@ -102,7 +102,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, return fmt.Errorf("open process for job assignment: %w", err) } - if err := windows.AssignProcessToJobObject(job, proc); err != nil { + if err = windows.AssignProcessToJobObject(job, proc); err != nil { _ = windows.CloseHandle(proc) _ = windows.CloseHandle(job) if resources.token != 0 { diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index f589f82a9..92ea426a6 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -25,6 +25,24 @@ type headerTransport struct { headers map[string]string } +func expandHomeCommandPath(command string) string { + if command == "" || command[0] != '~' { + return command + } + + home, err := os.UserHomeDir() + if err != nil { + return command + } + if command == "~" { + return home + } + if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") { + return filepath.Join(home, command[2:]) + } + return command +} + func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original req = req.Clone(req.Context()) @@ -99,10 +117,12 @@ func loadEnvFile(path string) (map[string]string, error) { // ServerConnection represents a connection to an MCP server type ServerConnection struct { - Name string - Client *mcp.Client - Session *mcp.ClientSession - Tools []*mcp.Tool + Name string + Config config.MCPServerConfig + Client *mcp.Client + Session *mcp.ClientSession + Tools []*mcp.Tool + reconnectMu sync.Mutex } // Manager manages multiple MCP server connections @@ -113,6 +133,8 @@ type Manager struct { wg sync.WaitGroup // tracks in-flight CallTool calls } +var connectServerFunc = connectServer + // NewManager creates a new MCP manager func NewManager() *Manager { return &Manager{ @@ -242,6 +264,28 @@ func (m *Manager) ConnectServer( name string, cfg config.MCPServerConfig, ) error { + conn, err := connectServerFunc(ctx, name, cfg) + if err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.closed.Load() { + _ = conn.Session.Close() + return fmt.Errorf("manager is closed") + } + + m.servers[name] = conn + return nil +} + +func connectServer( + ctx context.Context, + name string, + cfg config.MCPServerConfig, +) (*ServerConnection, error) { logger.InfoCF("mcp", "Connecting to MCP server", map[string]any{ "server": name, @@ -267,14 +311,14 @@ func (m *Manager) ConnectServer( } else if cfg.Command != "" { transportType = "stdio" } else { - return fmt.Errorf("either URL or command must be provided") + return nil, fmt.Errorf("either URL or command must be provided") } } switch transportType { case "sse", "http": if cfg.URL == "" { - return fmt.Errorf("URL is required for SSE/HTTP transport") + return nil, fmt.Errorf("URL is required for SSE/HTTP transport") } // Configure DisableStandaloneSSE based on transport type. @@ -316,7 +360,7 @@ func (m *Manager) ConnectServer( transport = sseTransport case "stdio": if cfg.Command == "" { - return fmt.Errorf("command is required for stdio transport") + return nil, fmt.Errorf("command is required for stdio transport") } logger.DebugCF("mcp", "Using stdio transport", map[string]any{ @@ -324,7 +368,7 @@ func (m *Manager) ConnectServer( "command": cfg.Command, }) // Create command with context - cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) + cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...) // Build environment variables with proper override semantics // Use a map to ensure config variables override file variables @@ -341,7 +385,7 @@ func (m *Manager) ConnectServer( if cfg.EnvFile != "" { envVars, err := loadEnvFile(cfg.EnvFile) if err != nil { - return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) + return nil, fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) } for k, v := range envVars { envMap[k] = v @@ -367,7 +411,7 @@ func (m *Manager) ConnectServer( cmd.Env = env transport = &isolatedCommandTransport{Command: cmd} default: - return fmt.Errorf( + return nil, fmt.Errorf( "unsupported transport type: %s (supported: stdio, sse, http)", transportType, ) @@ -376,7 +420,7 @@ func (m *Manager) ConnectServer( // Connect to server session, err := client.Connect(ctx, transport, nil) if err != nil { - return fmt.Errorf("failed to connect: %w", err) + return nil, fmt.Errorf("failed to connect: %w", err) } // Get server info @@ -390,38 +434,19 @@ func (m *Manager) ConnectServer( }) // List available tools if supported - var tools []*mcp.Tool - if initResult.Capabilities.Tools != nil { - for tool, err := range session.Tools(ctx, nil) { - if err != nil { - logger.WarnCF("mcp", "Error listing tool", - map[string]any{ - "server": name, - "error": err.Error(), - }) - continue - } - tools = append(tools, tool) - } - - logger.InfoCF("mcp", "Listed tools from MCP server", - map[string]any{ - "server": name, - "toolCount": len(tools), - }) + tools, err := listServerTools(ctx, name, session, initResult) + if err != nil { + _ = session.Close() + return nil, err } - // Store connection - m.mu.Lock() - m.servers[name] = &ServerConnection{ + return &ServerConnection{ Name: name, + Config: cfg, Client: client, Session: session, Tools: tools, - } - m.mu.Unlock() - - return nil + }, nil } // GetServers returns all connected servers @@ -480,12 +505,131 @@ func (m *Manager) CallTool( result, err := conn.Session.CallTool(ctx, params) if err != nil { + if shouldReconnectCallError(err) { + logger.WarnCF("mcp", "MCP server session was lost during tool call, reconnecting", + map[string]any{ + "server": serverName, + "tool": toolName, + "error": err.Error(), + }) + + reconnectedConn, reconnectErr := m.reconnectServer(ctx, serverName, conn) + if reconnectErr != nil { + return nil, fmt.Errorf("failed to recover lost MCP session: %w", reconnectErr) + } + + result, err = reconnectedConn.Session.CallTool(ctx, params) + if err == nil { + return result, nil + } + } + return nil, fmt.Errorf("failed to call tool: %w", err) } return result, nil } +func listServerTools( + ctx context.Context, + name string, + session *mcp.ClientSession, + initResult *mcp.InitializeResult, +) ([]*mcp.Tool, error) { + var tools []*mcp.Tool + if initResult.Capabilities.Tools == nil { + return tools, nil + } + + for tool, err := range session.Tools(ctx, nil) { + if err != nil { + logger.WarnCF("mcp", "Error listing tool", + map[string]any{ + "server": name, + "error": err.Error(), + }) + continue + } + tools = append(tools, tool) + } + + logger.InfoCF("mcp", "Listed tools from MCP server", + map[string]any{ + "server": name, + "toolCount": len(tools), + }) + + return tools, nil +} + +func shouldReconnectCallError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, mcp.ErrSessionMissing) { + return true + } + return strings.Contains(strings.ToLower(err.Error()), mcp.ErrSessionMissing.Error()) +} + +func (m *Manager) reconnectServer( + ctx context.Context, + serverName string, + staleConn *ServerConnection, +) (*ServerConnection, error) { + if staleConn == nil { + return nil, fmt.Errorf("server %s not found", serverName) + } + + staleConn.reconnectMu.Lock() + defer staleConn.reconnectMu.Unlock() + + if m.closed.Load() { + return nil, fmt.Errorf("manager is closed") + } + + m.mu.RLock() + currentConn, ok := m.servers[serverName] + m.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("server %s not found", serverName) + } + if currentConn != staleConn { + return currentConn, nil + } + + freshConn, err := connectServerFunc(ctx, serverName, staleConn.Config) + if err != nil { + return nil, err + } + + m.mu.Lock() + if m.closed.Load() { + m.mu.Unlock() + _ = freshConn.Session.Close() + return nil, fmt.Errorf("manager is closed") + } + + currentConn, ok = m.servers[serverName] + if !ok { + m.mu.Unlock() + _ = freshConn.Session.Close() + return nil, fmt.Errorf("server %s not found", serverName) + } + + if currentConn == staleConn { + m.servers[serverName] = freshConn + staleToClose := staleConn + m.mu.Unlock() + _ = staleToClose.Session.Close() + return freshConn, nil + } + + m.mu.Unlock() + _ = freshConn.Session.Close() + return currentConn, nil +} + // Close closes all server connections func (m *Manager) Close() error { // Use Swap to atomically set closed=true and get the previous value diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index f353942ab..682d4c346 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -2,11 +2,16 @@ package mcp import ( "context" + "encoding/json" + "fmt" + "io" "os" "path/filepath" "strings" + "sync" "testing" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" @@ -136,6 +141,22 @@ func TestLoadEnvFileNotFound(t *testing.T) { } } +func TestExpandHomeCommandPath(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + want := filepath.Join(homeDir, "bin", "my-mcp") + got := expandHomeCommandPath("~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")) + if got != want { + t.Fatalf("expandHomeCommandPath() = %q, want %q", got, want) + } + + if got := expandHomeCommandPath("npx"); got != "npx" { + t.Fatalf("expandHomeCommandPath() should leave bare commands unchanged, got %q", got) + } +} + func TestEnvFilePriority(t *testing.T) { // Create a temporary .env file tmpDir := t.TempDir() @@ -296,6 +317,81 @@ func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) { }) } +func TestCallTool_ReconnectsWhenHTTPServerLosesSession(t *testing.T) { + originalConnectServerFunc := connectServerFunc + t.Cleanup(func() { + connectServerFunc = originalConnectServerFunc + }) + + staleConn, staleTransport, err := newScriptedServerConnection( + "session-1", + nil, + fmt.Errorf(`sending "tools/call": failed to connect (session ID: session-1): %w`, sdkmcp.ErrSessionMissing), + ) + if err != nil { + t.Fatalf("newScriptedServerConnection(stale) error = %v", err) + } + freshConn, freshTransport, err := newScriptedServerConnection( + "session-2", + &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{ + &sdkmcp.TextContent{Text: "reconnected"}, + }, + }, + nil, + ) + if err != nil { + t.Fatalf("newScriptedServerConnection(fresh) error = %v", err) + } + + connectCalls := 0 + connectServerFunc = func(ctx context.Context, name string, cfg config.MCPServerConfig) (*ServerConnection, error) { + connectCalls++ + if connectCalls == 1 { + return freshConn, nil + } + return nil, fmt.Errorf("unexpected reconnect attempt %d", connectCalls) + } + + mgr := NewManager() + mgr.servers["flaky"] = staleConn + + result, err := mgr.CallTool(context.Background(), "flaky", "echo", map[string]any{ + "query": "hello", + }) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if result == nil || len(result.Content) != 1 { + t.Fatalf("CallTool() returned unexpected content: %#v", result) + } + + text, ok := result.Content[0].(*sdkmcp.TextContent) + if !ok { + t.Fatalf("CallTool() content type = %T, want *sdkmcp.TextContent", result.Content[0]) + } + if text.Text != "reconnected" { + t.Fatalf("CallTool() text = %q, want %q", text.Text, "reconnected") + } + + conn, ok := mgr.GetServer("flaky") + if !ok { + t.Fatal("expected flaky server to remain connected after reconnect") + } + if conn.Session.ID() != "session-2" { + t.Fatalf("Session.ID() = %q, want %q", conn.Session.ID(), "session-2") + } + if connectCalls != 1 { + t.Fatalf("connectCalls = %d, want 1", connectCalls) + } + if staleTransport.toolCallCalls != 1 { + t.Fatalf("stale toolCallCalls = %d, want 1", staleTransport.toolCallCalls) + } + if freshTransport.toolCallCalls != 1 { + t.Fatalf("fresh toolCallCalls = %d, want 1", freshTransport.toolCallCalls) + } +} + func TestClose_IdempotentOnEmptyManager(t *testing.T) { mgr := NewManager() @@ -306,3 +402,138 @@ func TestClose_IdempotentOnEmptyManager(t *testing.T) { t.Fatalf("second close should be idempotent, got: %v", err) } } + +func newScriptedServerConnection( + sessionID string, + toolCallResult *sdkmcp.CallToolResult, + toolCallErr error, +) (*ServerConnection, *scriptedTransport, error) { + transport := &scriptedTransport{ + sessionID: sessionID, + toolCallResult: toolCallResult, + toolCallErr: toolCallErr, + } + + client := sdkmcp.NewClient(&sdkmcp.Implementation{ + Name: "picoclaw-test", + Version: "1.0.0", + }, nil) + session, err := client.Connect(context.Background(), transport, nil) + if err != nil { + return nil, nil, err + } + + return &ServerConnection{ + Name: "flaky", + Config: config.MCPServerConfig{Enabled: true, Type: "http", URL: "https://example.invalid/mcp"}, + Client: client, + Session: session, + Tools: []*sdkmcp.Tool{ + { + Name: "echo", + Description: "Echo test tool", + InputSchema: map[string]any{"type": "object"}, + }, + }, + }, transport, nil +} + +type scriptedTransport struct { + sessionID string + toolCallResult *sdkmcp.CallToolResult + toolCallErr error + + mu sync.Mutex + toolCallCalls int + closed bool + incoming chan jsonrpc.Message +} + +func (t *scriptedTransport) Connect(context.Context) (sdkmcp.Connection, error) { + if t.incoming == nil { + t.incoming = make(chan jsonrpc.Message, 4) + } + return t, nil +} + +func (t *scriptedTransport) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg, ok := <-t.incoming: + if !ok { + return nil, io.EOF + } + return msg, nil + } +} + +func (t *scriptedTransport) Write(ctx context.Context, msg jsonrpc.Message) error { + req, ok := msg.(*jsonrpc.Request) + if !ok { + return nil + } + + switch req.Method { + case "initialize": + payload, err := json.Marshal(&sdkmcp.InitializeResult{ + ProtocolVersion: "2025-11-25", + ServerInfo: &sdkmcp.Implementation{ + Name: "scripted-test-server", + Version: "1.0.0", + }, + Capabilities: &sdkmcp.ServerCapabilities{ + Tools: &sdkmcp.ToolCapabilities{}, + }, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}: + return nil + } + + case "notifications/initialized": + return nil + + case "tools/call": + t.mu.Lock() + t.toolCallCalls++ + t.mu.Unlock() + + if t.toolCallErr != nil { + return t.toolCallErr + } + + payload, err := json.Marshal(t.toolCallResult) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}: + return nil + } + } + + return fmt.Errorf("unexpected method %q", req.Method) +} + +func (t *scriptedTransport) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return nil + } + t.closed = true + close(t.incoming) + return nil +} + +func (t *scriptedTransport) SessionID() string { + return t.sessionID +} diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 8d3320f3f..492205114 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -10,12 +10,14 @@ import ( "log" "os" "path/filepath" + "sort" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" ) const ( @@ -405,12 +407,9 @@ func (s *JSONLStore) promoteAliasHistoryLocked( } func (s *JSONLStore) sessionHasVisibleContentLocked(sessionKey string, meta SessionMeta) (bool, error) { - if meta.Count-meta.Skip > 0 || strings.TrimSpace(meta.Summary) != "" { + if strings.TrimSpace(meta.Summary) != "" { return true, nil } - if meta.Count != 0 || meta.Skip != 0 { - return false, nil - } history, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) if err != nil { return false, err @@ -482,6 +481,9 @@ func readMessages(path string, skip int) ([]providers.Message, error) { lineNum, filepath.Base(path), err) continue } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } msgs = append(msgs, msg) } if scanner.Err() != nil { @@ -494,28 +496,44 @@ func readMessages(path string, skip int) ([]providers.Message, error) { return msgs, nil } -// countLines counts the total number of non-empty lines in a .jsonl file. -// Used by TruncateHistory to reconcile a stale meta.Count without -// the overhead of unmarshaling every message. -func countLines(path string) (int, error) { +// scanRetainedMessageLines returns the total number of non-empty raw JSONL +// lines plus the raw line numbers that survive readMessages filtering. +// TruncateHistory uses this to compute keepLast against retained messages +// while preserving the raw-line skip offset stored in metadata. +func scanRetainedMessageLines(path string) (int, []int, error) { f, err := os.Open(path) if os.IsNotExist(err) { - return 0, nil + return 0, []int{}, nil } if err != nil { - return 0, fmt.Errorf("memory: open jsonl: %w", err) + return 0, nil, fmt.Errorf("memory: open jsonl: %w", err) } defer f.Close() - n := 0 + rawCount := 0 + retained := make([]int, 0) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) for scanner.Scan() { - if len(scanner.Bytes()) > 0 { - n++ + line := scanner.Bytes() + if len(line) == 0 { + continue } + rawCount++ + + var msg providers.Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + retained = append(retained, rawCount) } - return n, scanner.Err() + if err := scanner.Err(); err != nil { + return 0, nil, err + } + return rawCount, retained, nil } func (s *JSONLStore) AddMessage( @@ -535,6 +553,10 @@ func (s *JSONLStore) AddFullMessage( // addMsg is the shared implementation for AddMessage and AddFullMessage. func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return nil + } + l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() @@ -655,24 +677,26 @@ func (s *JSONLStore) TruncateHistory( return err } - // Always reconcile meta.Count with the actual line count on disk. - // A crash between the JSONL append and the meta update in addMsg - // leaves meta.Count stale (e.g. file has 101 lines but meta says - // 100). Counting lines is cheap — no unmarshal, just a scan — and - // TruncateHistory is not a hot path, so always re-count. - n, countErr := countLines(s.jsonlPath(sessionKey)) - if countErr != nil { - return countErr + rawCount, retainedRawLines, scanErr := scanRetainedMessageLines(s.jsonlPath(sessionKey)) + if scanErr != nil { + return scanErr } - meta.Count = n - - if keepLast <= 0 { + meta.Count = rawCount + if meta.Skip > meta.Count { meta.Skip = meta.Count - } else { - effective := meta.Count - meta.Skip - if keepLast < effective { - meta.Skip = meta.Count - keepLast - } + } + + activeStart := sort.Search(len(retainedRawLines), func(i int) bool { + return retainedRawLines[i] > meta.Skip + }) + activeRetainedCount := len(retainedRawLines) - activeStart + + switch { + case keepLast <= 0 || activeRetainedCount == 0: + meta.Skip = meta.Count + case keepLast < activeRetainedCount: + activeRawLines := retainedRawLines[activeStart:] + meta.Skip = activeRawLines[activeRetainedCount-keepLast-1] } meta.UpdatedAt = time.Now() @@ -684,6 +708,8 @@ func (s *JSONLStore) SetHistory( sessionKey string, history []providers.Message, ) error { + history = messageutil.FilterInvalidHistoryMessages(history) + l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() @@ -762,6 +788,8 @@ func (s *JSONLStore) Compact( func (s *JSONLStore) rewriteJSONL( sessionKey string, msgs []providers.Message, ) error { + msgs = messageutil.FilterInvalidHistoryMessages(msgs) + var buf bytes.Buffer for i, msg := range msgs { line, err := json.Marshal(msg) diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index b64c1b25f..3a7b98130 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "reflect" + "strings" "sync" "testing" + "time" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -155,6 +157,27 @@ func TestAddFullMessage_ToolCallID(t *testing.T) { } } +func TestAddFullMessage_DropsTransientAssistantThought(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + err := store.AddFullMessage(ctx, "transient-thought", providers.Message{ + Role: "assistant", + ReasoningContent: "internal chain of thought", + }) + if err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "transient-thought") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 0 { + t.Fatalf("expected transient thought to be discarded, got %d messages", len(history)) + } +} + func TestGetHistory_EmptySession(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -243,6 +266,46 @@ func TestSetSummary_GetSummary(t *testing.T) { } } +func TestSetHistory_DropsTransientAssistantThought(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + newHistory := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", ReasoningContent: "internal chain of thought"}, + {Role: "assistant", Content: "visible answer", ReasoningContent: "visible thought"}, + } + + err := store.SetHistory(ctx, "replace", newHistory) + if err != nil { + t.Fatalf("SetHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "replace") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected transient thought to be removed, got %d messages", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Fatalf("history[0] = %+v, want user/hello", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "visible answer" || + history[1].ReasoningContent != "visible thought" { + t.Fatalf("history[1] = %+v, want assistant visible answer with reasoning", history[1]) + } + + data, err := os.ReadFile(store.jsonlPath("replace")) + if err != nil { + t.Fatalf("ReadFile(jsonl): %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("jsonl line count = %d, want 2", len(lines)) + } +} + func TestSessionMetaScopeAndAliasesPersist(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -733,6 +796,56 @@ func TestTruncateHistory_StaleMetaCount(t *testing.T) { } } +func TestTruncateHistory_IgnoresTransientThoughtForKeepLast(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + sessionKey := "transient-keep-last" + now := time.Now() + + rawJSONL := strings.Join([]string{ + `{"role":"user","content":"a"}`, + `{"role":"assistant","content":"b"}`, + `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`, + `{"role":"user","content":"c"}`, + `{"role":"assistant","content":"d"}`, + }, "\n") + "\n" + if err := os.WriteFile(store.jsonlPath(sessionKey), []byte(rawJSONL), 0o644); err != nil { + t.Fatalf("WriteFile(jsonl): %v", err) + } + if err := store.writeMeta(sessionKey, SessionMeta{ + Key: sessionKey, + Count: 5, + Skip: 0, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("writeMeta: %v", err) + } + + if err := store.TruncateHistory(ctx, sessionKey, 2); err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + history, err := store.GetHistory(ctx, sessionKey) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected 2 retained messages, got %d", len(history)) + } + if history[0].Content != "c" || history[1].Content != "d" { + t.Fatalf("kept history = %+v, want c,d", history) + } + + meta, err := store.readMeta(sessionKey) + if err != nil { + t.Fatalf("readMeta: %v", err) + } + if meta.Skip != 2 { + t.Fatalf("meta.Skip = %d, want 2 raw lines skipped", meta.Skip) + } +} + func TestCrashRecovery_PartialLine(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go index f7c1f42b2..00601195f 100644 --- a/pkg/pid/pidfile.go +++ b/pkg/pid/pidfile.go @@ -58,7 +58,12 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) { if data, err := readPidFileUnlocked(pidPath); err == nil { if os.Getpid() != data.PID { logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version) - if isProcessRunning(data.PID) { + // PID 1 is typically init/systemd on the host or the entrypoint + // inside a container. When a container stops and leaves behind a + // PID file on a shared volume, the host's PID 1 (init) would + // pass the isProcessRunning check, blocking new gateway starts. + // Treat recorded PID 1 as always stale. + if data.PID != 1 && isProcessRunning(data.PID) { return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version) } logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath) @@ -124,6 +129,14 @@ func ReadPidFileWithCheck(homePath string) *PidFileData { return nil } + // Treat PID 1 as stale when we are not PID 1 ourselves (container + // leftover on a shared volume — host PID 1 is init, not gateway). + if data.PID == 1 && os.Getpid() != 1 { + logger.Debugf("stale container PID 1, remove pid file: %s", pidPath) + os.Remove(pidPath) + return nil + } + if !isProcessRunning(data.PID) { logger.Debugf("process not running, remove pid file: %s", pidPath) os.Remove(pidPath) diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go index 2da44bbbc..2d3c11f63 100644 --- a/pkg/pid/pidfile_test.go +++ b/pkg/pid/pidfile_test.go @@ -278,6 +278,46 @@ func TestRemovePidFileIfPIDMismatch(t *testing.T) { } } +// TestWritePidFileContainerPID1 verifies that a leftover PID file with PID 1 +// (typical container entrypoint) is treated as stale and overwritten. +func TestWritePidFileContainerPID1(t *testing.T) { + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile should treat PID 1 as stale, got error: %v", err) + } + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } +} + +// TestReadPidFileWithCheckContainerPID1 verifies that a leftover PID file +// with PID 1 is treated as stale and cleaned up. +func TestReadPidFileWithCheckContainerPID1(t *testing.T) { + if os.Getpid() == 1 { + t.Skip("test not meaningful when running as PID 1") + } + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for PID 1 leftover") + } + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("PID 1 leftover file should be removed") + } +} + // TestReadPidFileUnlockedInvalidJSON returns error for malformed content. func TestReadPidFileUnlockedInvalidJSON(t *testing.T) { dir := tmpDir(t) diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index d4ceaab2c..6f4aadb8b 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -10,6 +10,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -42,7 +43,7 @@ func NewProvider(token string) *Provider { } func NewProviderWithBaseURL(token, apiBase string) *Provider { - baseURL := normalizeBaseURL(apiBase) + baseURL := common.NormalizeBaseURL(apiBase, defaultBaseURL, false) client := anthropic.NewClient( option.WithAuthToken(token), option.WithBaseURL(baseURL), @@ -385,20 +386,3 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { }, } } - -func normalizeBaseURL(apiBase string) string { - base := strings.TrimSpace(apiBase) - if base == "" { - return defaultBaseURL - } - - base = strings.TrimRight(base, "/") - if before, ok := strings.CutSuffix(base, "/v1"); ok { - base = before - } - if base == "" { - return defaultBaseURL - } - - return base -} diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index 1e865b709..672fb9324 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -51,7 +52,7 @@ func NewProvider(apiKey, apiBase, userAgent string) *Provider { // NewProviderWithTimeout creates a provider with custom request timeout. func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider { - baseURL := normalizeBaseURL(apiBase) + baseURL := common.NormalizeBaseURL(apiBase, defaultBaseURL, true) timeout := defaultRequestTimeout if timeoutSeconds > 0 { timeout = time.Duration(timeoutSeconds) * time.Second @@ -161,7 +162,7 @@ func buildRequestBody( options map[string]any, ) (map[string]any, error) { // max_tokens is required and guaranteed by agent loop - maxTokens, ok := asInt(options["max_tokens"]) + maxTokens, ok := common.AsInt(options["max_tokens"]) if !ok { return nil, fmt.Errorf("max_tokens is required in options") } @@ -173,7 +174,7 @@ func buildRequestBody( } // Set temperature from options - if temp, ok := asFloat(options["temperature"]); ok { + if temp, ok := common.AsFloat(options["temperature"]); ok { result["temperature"] = temp } @@ -361,61 +362,6 @@ func parseResponseBody(body []byte) (*LLMResponse, error) { }, nil } -// normalizeBaseURL ensures the base URL is properly formatted. -// It removes /v1 suffix if present (to avoid duplication) and always appends /v1. -// This handles edge cases like "https://api.example.com/v1/proxy" correctly. -func normalizeBaseURL(apiBase string) string { - base := strings.TrimSpace(apiBase) - if base == "" { - return defaultBaseURL - } - - // Remove trailing slashes - base = strings.TrimRight(base, "/") - - // Remove /v1 suffix if present (will be re-added) - // This prevents duplication for URLs like "https://api.example.com/v1/proxy" - if before, ok := strings.CutSuffix(base, "/v1"); ok { - base = before - } - - // Ensure we don't have an empty string after cutting - if base == "" { - return defaultBaseURL - } - - // Add /v1 suffix (required by Anthropic Messages API) - return base + "/v1" -} - -// Helper functions for type conversion - -func asInt(v any) (int, bool) { - switch val := v.(type) { - case int: - return val, true - case float64: - return int(val), true - case int64: - return int(val), true - default: - return 0, false - } -} - -func asFloat(v any) (float64, bool) { - switch val := v.(type) { - case float64: - return val, true - case int: - return float64(val), true - case int64: - return float64(val), true - default: - return 0, false - } -} - // Anthropic API response structures type anthropicMessageResponse struct { diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index ba9d24b66..6401d84bd 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -372,44 +372,6 @@ func TestParseResponseBody(t *testing.T) { } } -func TestNormalizeBaseURL(t *testing.T) { - tests := []struct { - name string - apiBase string - expected string - }{ - { - name: "empty string defaults to official API", - apiBase: "", - expected: "https://api.anthropic.com/v1", - }, - { - name: "URL without /v1 gets it appended", - apiBase: "https://api.example.com/anthropic", - expected: "https://api.example.com/anthropic/v1", - }, - { - name: "URL with /v1 remains unchanged", - apiBase: "https://api.example.com/v1", - expected: "https://api.example.com/v1", - }, - { - name: "URL with trailing slash gets cleaned", - apiBase: "https://api.example.com/anthropic/", - expected: "https://api.example.com/anthropic/v1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeBaseURL(tt.apiBase) - if got != tt.expected { - t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.apiBase, got, tt.expected) - } - }) - } -} - func TestNewProvider(t *testing.T) { provider := NewProvider("test-key", "https://api.example.com", "") if provider == nil { diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/cli/claude_cli_provider.go similarity index 99% rename from pkg/providers/claude_cli_provider.go rename to pkg/providers/cli/claude_cli_provider.go index c3d98c555..62851ca3a 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/cli/claude_cli_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "bytes" diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/cli/claude_cli_provider_integration_test.go similarity index 99% rename from pkg/providers/claude_cli_provider_integration_test.go rename to pkg/providers/cli/claude_cli_provider_integration_test.go index f6e0d787a..cdfe7060e 100644 --- a/pkg/providers/claude_cli_provider_integration_test.go +++ b/pkg/providers/cli/claude_cli_provider_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/cli/claude_cli_provider_test.go similarity index 92% rename from pkg/providers/claude_cli_provider_test.go rename to pkg/providers/cli/claude_cli_provider_test.go index bc9960f0c..ddef84ffc 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/cli/claude_cli_provider_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" @@ -9,8 +9,6 @@ import ( "strings" "testing" "time" - - "github.com/sipeed/picoclaw/pkg/config" ) // --- Compile-time interface check --- @@ -409,83 +407,6 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { } } -// --- CreateProvider factory tests --- - -func TestCreateProvider_ClaudeCli(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, - } - cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-cli) error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "/test/ws" { - t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws") - } -} - -func TestCreateProvider_ClaudeCode(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claude-code", Model: "claude-cli/claude-code"}, - } - cfg.Agents.Defaults.ModelName = "claude-code" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-code) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCodec(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claudecode", Model: "claude-cli/claudecode"}, - } - cfg.Agents.Defaults.ModelName = "claudecode" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claudecode) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { - cfg := config.DefaultConfig() - cfg.ModelList = []*config.ModelConfig{ - {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, - } - cfg.Agents.Defaults.ModelName = "claude-cli" - cfg.Agents.Defaults.Workspace = "" - - provider, _, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "." { - t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".") - } -} - // --- messagesToPrompt tests --- func TestMessagesToPrompt_SingleUser(t *testing.T) { diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/cli/codex_cli_credentials.go similarity index 99% rename from pkg/providers/codex_cli_credentials.go rename to pkg/providers/cli/codex_cli_credentials.go index c5b25f040..95e289097 100644 --- a/pkg/providers/codex_cli_credentials.go +++ b/pkg/providers/cli/codex_cli_credentials.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "encoding/json" diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/cli/codex_cli_credentials_test.go similarity index 99% rename from pkg/providers/codex_cli_credentials_test.go rename to pkg/providers/cli/codex_cli_credentials_test.go index 1e88c1120..abad6e248 100644 --- a/pkg/providers/codex_cli_credentials_test.go +++ b/pkg/providers/cli/codex_cli_credentials_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "os" diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/cli/codex_cli_provider.go similarity index 99% rename from pkg/providers/codex_cli_provider.go rename to pkg/providers/cli/codex_cli_provider.go index a9c8b692a..d1a23c329 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/cli/codex_cli_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "bufio" diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/cli/codex_cli_provider_integration_test.go similarity index 99% rename from pkg/providers/codex_cli_provider_integration_test.go rename to pkg/providers/cli/codex_cli_provider_integration_test.go index 17a8305ad..af18b8c6d 100644 --- a/pkg/providers/codex_cli_provider_integration_test.go +++ b/pkg/providers/cli/codex_cli_provider_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/cli/codex_cli_provider_test.go similarity index 98% rename from pkg/providers/codex_cli_provider_test.go rename to pkg/providers/cli/codex_cli_provider_test.go index 0f66e25f4..8338fbc91 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/cli/codex_cli_provider_test.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -400,6 +401,9 @@ func TestCodexCliProvider_GetDefaultModel(t *testing.T) { func createMockCodexCLI(t *testing.T, events []string) string { t.Helper() + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") @@ -471,6 +475,9 @@ func TestCodexCliProvider_MockCLI_Error(t *testing.T) { } func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } // Mock script that captures args to verify model flag is passed tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") @@ -517,6 +524,9 @@ echo '{"type":"turn.completed"}'` } func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } // Script that sleeps forever tmpDir := t.TempDir() scriptPath := filepath.Join(tmpDir, "codex") diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/cli/github_copilot_provider.go similarity index 99% rename from pkg/providers/github_copilot_provider.go rename to pkg/providers/cli/github_copilot_provider.go index 472c14257..d1d8a3e23 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/cli/github_copilot_provider.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "context" diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/cli/tool_call_extract.go similarity index 98% rename from pkg/providers/tool_call_extract.go rename to pkg/providers/cli/tool_call_extract.go index 7ddea0e99..f1d1886ea 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/cli/tool_call_extract.go @@ -1,4 +1,4 @@ -package providers +package cliprovider import ( "encoding/json" diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/cli/toolcall_utils.go similarity index 83% rename from pkg/providers/toolcall_utils.go rename to pkg/providers/cli/toolcall_utils.go index 7d0908158..1f58c9a26 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/cli/toolcall_utils.go @@ -3,7 +3,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package providers +package cliprovider import ( "encoding/json" @@ -55,6 +55,12 @@ func buildCLIToolsPrompt(tools []ToolDefinition) string { func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc + if normalized.ThoughtSignature == "" && + normalized.ExtraContent != nil && + normalized.ExtraContent.Google != nil { + normalized.ThoughtSignature = normalized.ExtraContent.Google.ThoughtSignature + } + // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name @@ -77,8 +83,9 @@ func NormalizeToolCall(tc ToolCall) ToolCall { argsJSON, _ := json.Marshal(normalized.Arguments) if normalized.Function == nil { normalized.Function = &FunctionCall{ - Name: normalized.Name, - Arguments: string(argsJSON), + Name: normalized.Name, + Arguments: string(argsJSON), + ThoughtSignature: normalized.ThoughtSignature, } } else { if normalized.Function.Name == "" { @@ -90,6 +97,12 @@ func NormalizeToolCall(tc ToolCall) ToolCall { if normalized.Function.Arguments == "" { normalized.Function.Arguments = string(argsJSON) } + if normalized.Function.ThoughtSignature == "" { + normalized.Function.ThoughtSignature = normalized.ThoughtSignature + } + if normalized.ThoughtSignature == "" { + normalized.ThoughtSignature = normalized.Function.ThoughtSignature + } } return normalized diff --git a/pkg/providers/cli/types.go b/pkg/providers/cli/types.go new file mode 100644 index 000000000..f15897adf --- /dev/null +++ b/pkg/providers/cli/types.go @@ -0,0 +1,28 @@ +package cliprovider + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} diff --git a/pkg/providers/cli_facade.go b/pkg/providers/cli_facade.go new file mode 100644 index 000000000..6580291bd --- /dev/null +++ b/pkg/providers/cli_facade.go @@ -0,0 +1,40 @@ +package providers + +import ( + "time" + + cliprovider "github.com/sipeed/picoclaw/pkg/providers/cli" +) + +type ( + ClaudeCliProvider = cliprovider.ClaudeCliProvider + CodexCliProvider = cliprovider.CodexCliProvider + CodexCliAuth = cliprovider.CodexCliAuth + GitHubCopilotProvider = cliprovider.GitHubCopilotProvider +) + +const CodexHomeEnvVar = cliprovider.CodexHomeEnvVar + +func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { + return cliprovider.NewClaudeCliProvider(workspace) +} + +func NewCodexCliProvider(workspace string) *CodexCliProvider { + return cliprovider.NewCodexCliProvider(workspace) +} + +func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { + return cliprovider.NewGitHubCopilotProvider(uri, connectMode, model) +} + +func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) { + return cliprovider.ReadCodexCliCredentials() +} + +func CreateCodexCliTokenSource() func() (string, string, error) { + return cliprovider.CreateCodexCliTokenSource() +} + +func NormalizeToolCall(tc ToolCall) ToolCall { + return cliprovider.NormalizeToolCall(tc) +} diff --git a/pkg/providers/cli_factory_test.go b/pkg/providers/cli_factory_test.go new file mode 100644 index 000000000..b00eafb9f --- /dev/null +++ b/pkg/providers/cli_factory_test.go @@ -0,0 +1,99 @@ +package providers + +import ( + "reflect" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func testProviderWorkspace(t *testing.T, provider any) string { + t.Helper() + + v := reflect.ValueOf(provider) + if v.Kind() != reflect.Ptr || v.IsNil() { + t.Fatalf("provider = %T, want non-nil pointer", provider) + } + + field := v.Elem().FieldByName("workspace") + if !field.IsValid() || field.Kind() != reflect.String { + t.Fatalf("provider %T does not expose workspace field", provider) + } + + return field.String() +} + +func TestCreateProvider_ClaudeCli(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"}, + } + cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-cli) error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) + } + if got := testProviderWorkspace(t, cliProvider); got != "/test/ws" { + t.Errorf("workspace = %q, want %q", got, "/test/ws") + } +} + +func TestCreateProvider_ClaudeCode(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-code", Model: "claude-cli/claude-code"}, + } + cfg.Agents.Defaults.ModelName = "claude-code" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claude-code) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCodec(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claudecode", Model: "claude-cli/claudecode"}, + } + cfg.Agents.Defaults.ModelName = "claudecode" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider(claudecode) error = %v", err) + } + if _, ok := provider.(*ClaudeCliProvider); !ok { + t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) + } +} + +func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{ + {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"}, + } + cfg.Agents.Defaults.ModelName = "claude-cli" + cfg.Agents.Defaults.Workspace = "" + + provider, _, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider error = %v", err) + } + + cliProvider, ok := provider.(*ClaudeCliProvider) + if !ok { + t.Fatalf("returned %T, want *ClaudeCliProvider", provider) + } + if got := testProviderWorkspace(t, cliProvider); got != "." { + t.Errorf("workspace = %q, want %q (default)", got, ".") + } +} diff --git a/pkg/providers/common/anthropic_common.go b/pkg/providers/common/anthropic_common.go new file mode 100644 index 000000000..92dace9ac --- /dev/null +++ b/pkg/providers/common/anthropic_common.go @@ -0,0 +1,27 @@ +package common + +import "strings" + +// NormalizeBaseURL ensures the Anthropic base URL is properly formatted. +// It removes a trailing /v1 suffix if present (to avoid duplication), then +// re-appends /v1 when appendV1Suffix is true. An empty apiBase falls back to +// defaultBaseURL. +func NormalizeBaseURL(apiBase, defaultBaseURL string, appendV1Suffix bool) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return defaultBaseURL + } + + base = strings.TrimRight(base, "/") + if before, ok := strings.CutSuffix(base, "/v1"); ok { + base = before + } + if base == "" { + return defaultBaseURL + } + + if appendV1Suffix { + return base + "/v1" + } + return base +} diff --git a/pkg/providers/common/anthropic_common_test.go b/pkg/providers/common/anthropic_common_test.go new file mode 100644 index 000000000..7563141b5 --- /dev/null +++ b/pkg/providers/common/anthropic_common_test.go @@ -0,0 +1,59 @@ +package common + +import "testing" + +func TestNormalizeAnthropicBaseURL(t *testing.T) { + const defaultURL = "https://api.anthropic.com" + const defaultURLWithV1 = "https://api.anthropic.com/v1" + + tests := []struct { + name string + apiBase string + defaultBase string + appendV1Suffix bool + expected string + }{ + {"empty with v1", "", defaultURLWithV1, true, defaultURLWithV1}, + {"empty without v1", "", defaultURL, false, defaultURL}, + { + "URL without v1 gets it appended", + "https://api.example.com/anthropic", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "URL without v1 stays as-is", + "https://api.example.com/anthropic", defaultURL, + false, "https://api.example.com/anthropic", + }, + { + "URL with v1 remains unchanged when appending", + "https://api.example.com/v1", defaultURLWithV1, + true, "https://api.example.com/v1", + }, + { + "URL with v1 gets it stripped when not appending", + "https://api.example.com/v1", defaultURL, + false, "https://api.example.com", + }, + { + "trailing slash cleaned with v1", + "https://api.example.com/anthropic/", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "trailing slash cleaned without v1", + "https://api.example.com/anthropic/", defaultURL, + false, "https://api.example.com/anthropic", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeBaseURL(tt.apiBase, tt.defaultBase, tt.appendV1Suffix) + if got != tt.expected { + t.Errorf("NormalizeAnthropicBaseURL(%q, %q, %v) = %q, want %q", + tt.apiBase, tt.defaultBase, tt.appendV1Suffix, got, tt.expected) + } + }) + } +} diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index 90142fb8b..5e03bc0c2 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -70,11 +70,23 @@ func NewHTTPClient(proxy string) *http.Client { // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openaiToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *openaiFunctionCall `json:"function,omitempty"` +} + +type openaiFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + ThoughtSignature string `json:"thought_signature,omitempty"` } // SerializeMessages converts internal Message structs to the OpenAI wire format. @@ -84,12 +96,13 @@ type openaiMessage struct { func SerializeMessages(messages []Message) []any { out := make([]any, 0, len(messages)) for _, m := range messages { + toolCalls := serializeToolCalls(m.ToolCalls) if len(m.Media) == 0 { out = append(out, openaiMessage{ Role: m.Role, Content: m.Content, ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, + ToolCalls: toolCalls, ToolCallID: m.ToolCallID, }) continue @@ -114,7 +127,7 @@ func SerializeMessages(messages []Message) []any { continue } - if format, data, ok := parseDataAudioURL(mediaURL); ok { + if format, data, ok := ParseDataAudioURL(mediaURL); ok { parts = append(parts, map[string]any{ "type": "input_audio", "input_audio": map[string]any{ @@ -132,8 +145,8 @@ func SerializeMessages(messages []Message) []any { if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent @@ -143,7 +156,57 @@ func SerializeMessages(messages []Message) []any { return out } -func parseDataAudioURL(mediaURL string) (format, data string, ok bool) { +func serializeToolCalls(toolCalls []ToolCall) []openaiToolCall { + if len(toolCalls) == 0 { + return nil + } + + out := make([]openaiToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + wireCall := openaiToolCall{ + ID: tc.ID, + Type: tc.Type, + } + + if tc.Function != nil { + thoughtSignature := tc.Function.ThoughtSignature + if thoughtSignature == "" { + thoughtSignature = tc.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + ThoughtSignature: thoughtSignature, + } + } else if tc.Name != "" || len(tc.Arguments) > 0 || tc.ThoughtSignature != "" { + thoughtSignature := tc.ThoughtSignature + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + argsJSON := "{}" + if len(tc.Arguments) > 0 { + if encoded, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encoded) + } + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Name, + Arguments: argsJSON, + ThoughtSignature: thoughtSignature, + } + } + + out = append(out, wireCall) + } + + return out +} + +// ParseDataAudioURL extracts the format and base64 data from a data:audio/... URL. +func ParseDataAudioURL(mediaURL string) (format, data string, ok bool) { if !strings.HasPrefix(mediaURL, "data:audio/") { return "", "", false } @@ -178,13 +241,15 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ID string `json:"id"` Type string `json:"type"` Function *struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + ThoughtSignature string `json:"thought_signature"` } `json:"function"` ExtraContent *struct { Google *struct { ThoughtSignature string `json:"thought_signature"` } `json:"google"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation"` } `json:"extra_content"` } `json:"tool_calls"` } `json:"message"` @@ -210,9 +275,11 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { arguments := make(map[string]any) name := "" - // Extract thought_signature from Gemini/Google-specific extra content thoughtSignature := "" - if tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { thoughtSignature = tc.ExtraContent.Google.ThoughtSignature } @@ -228,11 +295,20 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ThoughtSignature: thoughtSignature, } - if thoughtSignature != "" { - toolCall.ExtraContent = &ExtraContent{ - Google: &GoogleExtra{ + if thoughtSignature != "" || tc.ExtraContent != nil { + extraContent := &ExtraContent{ + ToolFeedbackExplanation: "", + } + if tc.ExtraContent != nil { + extraContent.ToolFeedbackExplanation = tc.ExtraContent.ToolFeedbackExplanation + } + if thoughtSignature != "" { + extraContent.Google = &GoogleExtra{ ThoughtSignature: thoughtSignature, - }, + } + } + if extraContent.Google != nil || strings.TrimSpace(extraContent.ToolFeedbackExplanation) != "" { + toolCall.ExtraContent = extraContent } } diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index c107bb665..3cf2f4285 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -162,6 +162,104 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { } } +func TestSerializeMessages_StripsInternalToolCallExtraContent(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + ThoughtSignature: "sig-1", + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ + ThoughtSignature: "sig-ignored-here", + }, + ToolFeedbackExplanation: "Read README.md first.", + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include internal extra_content: %s", payload) + } + if !strings.Contains(payload, "thought_signature") { + t.Fatalf("serialized payload should preserve function thought_signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesTopLevelThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + ThoughtSignature: "sig-1", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve top-level thought signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesGoogleExtraThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include extra_content: %s", payload) + } + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve google thought signature: %s", payload) + } +} + // --- ParseResponse tests --- func TestParseResponse_BasicContent(t *testing.T) { @@ -234,6 +332,27 @@ func TestParseResponse_WithReasoningContent(t *testing.T) { } } +func TestParseResponse_WithToolFeedbackExplanationExtraContent(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Check the current config before editing."}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ExtraContent == nil { + t.Fatal("ExtraContent is nil") + } + if out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation != "Check the current config before editing." { + t.Fatalf( + "ToolFeedbackExplanation = %q, want %q", + out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation, + "Check the current config before editing.", + ) + } +} + func TestParseResponse_InvalidJSON(t *testing.T) { _, err := ParseResponse(strings.NewReader("not json")) if err == nil { @@ -541,6 +660,37 @@ func TestAsFloat(t *testing.T) { } } +// --- ParseDataAudioURL tests --- + +func TestParseDataAudioURL(t *testing.T) { + tests := []struct { + name string + mediaURL string + wantFormat string + wantData string + wantOK bool + }{ + {"valid mp3", "data:audio/mp3;base64,SGVsbG8=", "mp3", "SGVsbG8=", true}, + {"valid wav", "data:audio/wav;base64,AAAA", "wav", "AAAA", true}, + {"not audio", "data:image/png;base64,abc", "", "", false}, + {"no comma", "data:audio/mp3;base64", "", "", false}, + {"empty data", "data:audio/mp3;base64,", "", "", false}, + {"empty string", "", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format, data, ok := ParseDataAudioURL(tt.mediaURL) + if ok != tt.wantOK || format != tt.wantFormat || data != tt.wantData { + t.Errorf( + "ParseDataAudioURL(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.mediaURL, format, data, ok, + tt.wantFormat, tt.wantData, tt.wantOK, + ) + } + }) + } +} + // --- WrapHTMLResponseError tests --- func TestWrapHTMLResponseError(t *testing.T) { @@ -626,3 +776,27 @@ func TestParseResponse_WithThoughtSignature(t *testing.T) { out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, "sig123") } } + +func TestParseResponse_WithFunctionThoughtSignature(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}","thought_signature":"sig456"}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ThoughtSignature != "sig456" { + t.Fatalf("ThoughtSignature = %q, want %q", out.ToolCalls[0].ThoughtSignature, "sig456") + } + if out.ToolCalls[0].ExtraContent == nil || out.ToolCalls[0].ExtraContent.Google == nil { + t.Fatal("ExtraContent.Google is nil") + } + if out.ToolCalls[0].ExtraContent.Google.ThoughtSignature != "sig456" { + t.Fatalf( + "ExtraContent.Google.ThoughtSignature = %q, want %q", + out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, + "sig456", + ) + } +} diff --git a/pkg/providers/common/google_common.go b/pkg/providers/common/google_common.go new file mode 100644 index 000000000..954c0c802 --- /dev/null +++ b/pkg/providers/common/google_common.go @@ -0,0 +1,70 @@ +package common + +import ( + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// NormalizeStoredToolCall extracts the tool name, arguments, and thought signature +// from a stored ToolCall. It handles both the top-level fields and the nested +// Function struct used by different API formats. +func NormalizeStoredToolCall(tc protocoltypes.ToolCall) (string, map[string]any, string) { + name := tc.Name + args := tc.Arguments + thoughtSignature := "" + + if name == "" && tc.Function != nil { + name = tc.Function.Name + thoughtSignature = tc.Function.ThoughtSignature + } else if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + + if args == nil { + args = map[string]any{} + } + + if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { + args = parsed + } + } + + return name, args, thoughtSignature +} + +// ResolveToolResponseName returns the tool name for a given tool call ID. +// It first checks the provided name map, then falls back to inferring the +// name from the call ID format. +func ResolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { + if toolCallID == "" { + return "" + } + + if name, ok := toolCallNames[toolCallID]; ok && name != "" { + return name + } + + return InferToolNameFromCallID(toolCallID) +} + +// InferToolNameFromCallID extracts a tool name from a call ID in the format +// "call__". Returns the original ID if it doesn't match. +func InferToolNameFromCallID(toolCallID string) string { + if !strings.HasPrefix(toolCallID, "call_") { + return toolCallID + } + + rest := strings.TrimPrefix(toolCallID, "call_") + if idx := strings.LastIndex(rest, "_"); idx > 0 { + candidate := rest[:idx] + if candidate != "" { + return candidate + } + } + + return toolCallID +} diff --git a/pkg/providers/common/google_common_test.go b/pkg/providers/common/google_common_test.go new file mode 100644 index 000000000..cc013dcd1 --- /dev/null +++ b/pkg/providers/common/google_common_test.go @@ -0,0 +1,146 @@ +package common + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +func TestNormalizeStoredToolCall_TopLevelFields(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "search", + Arguments: map[string]any{"q": "hello"}, + } + name, args, sig := NormalizeStoredToolCall(tc) + if name != "search" { + t.Errorf("name = %q, want %q", name, "search") + } + if args["q"] != "hello" { + t.Errorf("args[q] = %v, want %q", args["q"], "hello") + } + if sig != "" { + t.Errorf("thoughtSignature = %q, want empty", sig) + } +} + +func TestNormalizeStoredToolCall_FallsBackToFunction(t *testing.T) { + tc := protocoltypes.ToolCall{ + Function: &protocoltypes.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"/tmp"}`, + ThoughtSignature: "sig123", + }, + } + name, args, sig := NormalizeStoredToolCall(tc) + if name != "read_file" { + t.Errorf("name = %q, want %q", name, "read_file") + } + if args["path"] != "/tmp" { + t.Errorf("args[path] = %v, want %q", args["path"], "/tmp") + } + if sig != "sig123" { + t.Errorf("thoughtSignature = %q, want %q", sig, "sig123") + } +} + +func TestNormalizeStoredToolCall_TopLevelNameWithFunctionSig(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "search", + Arguments: map[string]any{"q": "hi"}, + Function: &protocoltypes.FunctionCall{ + ThoughtSignature: "thought1", + }, + } + name, _, sig := NormalizeStoredToolCall(tc) + if name != "search" { + t.Errorf("name = %q, want %q", name, "search") + } + if sig != "thought1" { + t.Errorf("thoughtSignature = %q, want %q", sig, "thought1") + } +} + +func TestNormalizeStoredToolCall_NilArgs(t *testing.T) { + tc := protocoltypes.ToolCall{Name: "test"} + _, args, _ := NormalizeStoredToolCall(tc) + if args == nil { + t.Fatal("args should not be nil") + } + if len(args) != 0 { + t.Errorf("args should be empty, got %v", args) + } +} + +func TestNormalizeStoredToolCall_EmptyArgsParseFromFunction(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "tool", + Arguments: map[string]any{}, + Function: &protocoltypes.FunctionCall{ + Arguments: `{"key":"val"}`, + }, + } + _, args, _ := NormalizeStoredToolCall(tc) + if args["key"] != "val" { + t.Errorf("args[key] = %v, want %q", args["key"], "val") + } +} + +func TestNormalizeStoredToolCall_InvalidFunctionJSON(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "tool", + Function: &protocoltypes.FunctionCall{ + Arguments: `not-json`, + }, + } + _, args, _ := NormalizeStoredToolCall(tc) + if len(args) != 0 { + t.Errorf("args should be empty for invalid JSON, got %v", args) + } +} + +func TestResolveToolResponseName_FromMap(t *testing.T) { + names := map[string]string{"call_1": "search"} + got := ResolveToolResponseName("call_1", names) + if got != "search" { + t.Errorf("got %q, want %q", got, "search") + } +} + +func TestResolveToolResponseName_EmptyID(t *testing.T) { + got := ResolveToolResponseName("", map[string]string{"x": "y"}) + if got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestResolveToolResponseName_FallsBackToInfer(t *testing.T) { + got := ResolveToolResponseName("call_search_docs_999", map[string]string{}) + if got != "search_docs" { + t.Errorf("got %q, want %q", got, "search_docs") + } +} + +func TestInferToolNameFromCallID(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"standard format", "call_search_docs_999", "search_docs"}, + {"single name", "call_read_123", "read"}, + {"no call prefix", "some_id", "some_id"}, + {"call prefix no underscore suffix", "call_onlyname", "call_onlyname"}, + {"empty string", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := InferToolNameFromCallID(tt.id) + if got != tt.want { + t.Errorf( + "InferToolNameFromCallID(%q) = %q, want %q", + tt.id, got, tt.want, + ) + } + }) + } +} diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index e7691aa93..88c92a47d 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -2,8 +2,12 @@ package providers import ( "context" + "errors" + "io" + "net" "regexp" "strings" + "syscall" ) // Common patterns in Go HTTP error messages @@ -50,6 +54,30 @@ var ( substr("context deadline exceeded"), } + networkPatterns = []errorPattern{ + substr("connection reset"), + substr("reset by peer"), + substr("connection refused"), + substr("connection aborted"), + substr("broken pipe"), + substr("use of closed network connection"), + substr("network is unreachable"), + substr("host is unreachable"), + substr("no such host"), + substr("temporary failure in name resolution"), + substr("server misbehaving"), + substr("read tcp"), + substr("write tcp"), + substr("dial tcp"), + substr("tls:"), + substr("x509:"), + substr("certificate"), + substr("handshake"), + substr("unexpected eof"), + substr("read: eof"), + substr("write: eof"), + } + billingPatterns = []errorPattern{ rxp(`\b402\b`), substr("payment required"), @@ -134,6 +162,17 @@ func ClassifyError(err error, provider, model string) *FailoverError { msg := strings.ToLower(err.Error()) + // Concrete transport errors should continue the fallback chain even when + // providers do not expose a structured HTTP status. + if reason := classifyByErrorType(err); reason != "" { + return &FailoverError{ + Reason: reason, + Provider: provider, + Model: model, + Wrapped: err, + } + } + // Image dimension/size errors: non-retriable, non-fallback. if IsImageDimensionError(msg) || IsImageSizeError(msg) { return &FailoverError{ @@ -170,6 +209,41 @@ func ClassifyError(err error, provider, model string) *FailoverError { return nil } +// classifyByErrorType maps concrete transport-layer error types to a retryable +// fallback reason before message heuristics are applied. +func classifyByErrorType(err error) FailoverReason { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return FailoverNetwork + } + + for _, transportErr := range []error{ + syscall.ECONNRESET, + syscall.ECONNABORTED, + syscall.ECONNREFUSED, + syscall.ETIMEDOUT, + syscall.EHOSTUNREACH, + syscall.ENETUNREACH, + syscall.EPIPE, + } { + if errors.Is(err, transportErr) { + if transportErr == syscall.ETIMEDOUT { + return FailoverTimeout + } + return FailoverNetwork + } + } + + var netErr net.Error + if errors.As(err, &netErr) { + if netErr.Timeout() { + return FailoverTimeout + } + return FailoverNetwork + } + + return "" +} + // classifyByStatus maps HTTP status codes to FailoverReason. func classifyByStatus(status int) FailoverReason { switch { @@ -204,6 +278,9 @@ func classifyByMessage(msg string) FailoverReason { if matchesAny(msg, timeoutPatterns) { return FailoverTimeout } + if matchesAny(msg, networkPatterns) { + return FailoverNetwork + } if matchesAny(msg, authPatterns) { return FailoverAuth } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 46b180835..571fb3882 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -4,9 +4,22 @@ import ( "context" "errors" "fmt" + "io" + "net" + "net/url" + "syscall" "testing" ) +type stubNetError struct { + msg string + timeout bool +} + +func (e stubNetError) Error() string { return e.msg } +func (e stubNetError) Timeout() bool { return e.timeout } +func (e stubNetError) Temporary() bool { return false } + func TestClassifyError_Nil(t *testing.T) { result := ClassifyError(nil, "openai", "gpt-4") if result != nil { @@ -154,6 +167,129 @@ func TestClassifyError_TimeoutPatterns(t *testing.T) { } } +func TestClassifyError_NetworkPatterns(t *testing.T) { + patterns := []string{ + `failed to send request: Post "https://example.com": tls: bad record MAC`, + "read tcp 10.20.0.1:61279->172.65.90.20:443: read: connection reset by peer", + "failed to send request: dial tcp 203.0.113.10:443: connect: connection refused", + "tls handshake failure", + "x509: certificate has expired or is not yet valid", + "read tcp 127.0.0.1:443: read: unexpected EOF", + "lookup api.example.com: no such host", + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverNetwork { + t.Errorf("pattern %q: reason = %q, want network", msg, result.Reason) + } + } +} + +func TestClassifyError_NetworkTypes(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "wrapped EOF", + err: &url.Error{ + Op: "Post", + URL: "https://example.com", + Err: io.EOF, + }, + }, + { + name: "dns error", + err: &net.DNSError{ + Err: "no such host", + Name: "api.example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ClassifyError(tt.err, "openai", "gpt-4") + if result == nil { + t.Fatal("expected non-nil") + } + if result.Reason != FailoverNetwork { + t.Fatalf("reason = %q, want network", result.Reason) + } + }) + } +} + +func TestClassifyError_TimeoutNetworkTypes(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "wrapped syscall timeout", + err: fmt.Errorf("dial tcp: %w", syscall.ETIMEDOUT), + }, + { + name: "net error timeout", + err: &url.Error{ + Op: "Post", + URL: "https://example.com", + Err: stubNetError{msg: "i/o timeout", timeout: true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ClassifyError(tt.err, "openai", "gpt-4") + if result == nil { + t.Fatal("expected non-nil") + } + if result.Reason != FailoverTimeout { + t.Fatalf("reason = %q, want timeout", result.Reason) + } + }) + } +} + +func TestClassifyError_TimeoutPatternsWinOverNetworkContext(t *testing.T) { + patterns := []string{ + `failed to send request: Post "https://example.com": dial tcp 203.0.113.10:443: i/o timeout`, + `read tcp 10.20.0.1:61279->172.65.90.20:443: i/o timeout`, + } + + for _, msg := range patterns { + err := errors.New(msg) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Errorf("pattern %q: expected non-nil", msg) + continue + } + if result.Reason != FailoverTimeout { + t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason) + } + } +} + +func TestClassifyError_NetworkPatternsWinOverAuthExpired(t *testing.T) { + err := errors.New( + `Post "https://example.com": tls: failed to verify certificate: x509: certificate has expired or is not yet valid`, + ) + result := ClassifyError(err, "openai", "gpt-4") + if result == nil { + t.Fatal("expected non-nil") + } + if result.Reason != FailoverNetwork { + t.Fatalf("reason = %q, want network", result.Reason) + } +} + func TestClassifyError_AuthPatterns(t *testing.T) { patterns := []string{ "invalid api key", @@ -286,6 +422,7 @@ func TestFailoverError_IsRetriable(t *testing.T) { {FailoverAuth, true}, {FailoverRateLimit, true}, {FailoverBilling, true}, + {FailoverNetwork, true}, {FailoverTimeout, true}, {FailoverOverloaded, true}, {FailoverFormat, false}, diff --git a/pkg/providers/facade_compat_test.go b/pkg/providers/facade_compat_test.go new file mode 100644 index 000000000..024c36abf --- /dev/null +++ b/pkg/providers/facade_compat_test.go @@ -0,0 +1,44 @@ +package providers + +import ( + "testing" + + cliprovider "github.com/sipeed/picoclaw/pkg/providers/cli" + oauthprovider "github.com/sipeed/picoclaw/pkg/providers/oauth" +) + +func TestNormalizeToolCallFacadeMatchesCLIProvider(t *testing.T) { + input := ToolCall{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + } + + got := NormalizeToolCall(input) + want := cliprovider.NormalizeToolCall(input) + + if got.Name != want.Name { + t.Fatalf("Name = %q, want %q", got.Name, want.Name) + } + if got.Function == nil || want.Function == nil { + t.Fatalf("Function should not be nil: got=%v want=%v", got.Function, want.Function) + } + if got.Function.Name != want.Function.Name { + t.Fatalf("Function.Name = %q, want %q", got.Function.Name, want.Function.Name) + } + if got.Function.Arguments != want.Function.Arguments { + t.Fatalf("Function.Arguments = %q, want %q", got.Function.Arguments, want.Function.Arguments) + } + if got.Arguments["path"] != want.Arguments["path"] { + t.Fatalf("Arguments[path] = %v, want %v", got.Arguments["path"], want.Arguments["path"]) + } +} + +func TestAntigravityFacadeSignaturesRemainAvailable(t *testing.T) { + var _ func(string) (string, error) = FetchAntigravityProjectID + var _ func(string, string) ([]AntigravityModelInfo, error) = FetchAntigravityModels + var _ AntigravityModelInfo = oauthprovider.AntigravityModelInfo{} +} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index ab68b326a..ce83c6c54 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -41,6 +41,7 @@ var protocolMetaByName = map[string]protocolMeta{ "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"}, "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + "qwen-portal": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, @@ -51,6 +52,7 @@ var protocolMetaByName = map[string]protocolMeta{ "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, + "zai": {defaultAPIBase: "https://api.z.ai/api/coding/paas/v4"}, "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true}, "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"}, "avian": {defaultAPIBase: "https://api.avian.io/v1"}, @@ -84,19 +86,43 @@ func createCodexAuthProvider() (LLMProvider, error) { return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil } -// ExtractProtocol extracts the protocol prefix and model identifier from a model string. -// If no prefix is specified, it defaults to "openai". +// ExtractProtocol extracts the effective protocol and model identifier from a +// model configuration. +// +// The explicit Provider field takes precedence. When Provider is empty, the +// protocol is inferred from Model. Plain model names default to "openai". +// Provider-prefixed models strip the first slash-separated segment from the +// returned model ID. +// +// The returned protocol is normalized to the provider's canonical spelling. // Examples: -// - "openai/gpt-4o" -> ("openai", "gpt-4o") -// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6") -// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol -func ExtractProtocol(model string) (protocol, modelID string) { - model = strings.TrimSpace(model) - protocol, modelID, found := strings.Cut(model, "/") +// - Model "openai/gpt-4o" -> ("openai", "gpt-4o") +// - Model "nvidia/z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") +// - Provider "nvidia", Model "z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") +// - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o") +// - Model "gpt-4o" -> ("openai", "gpt-4o") +func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { + if cfg == nil { + return "", "" + } + + model := strings.TrimSpace(cfg.Model) + 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 } - return protocol, modelID + protocol = strings.TrimSpace(protocol) + if protocol == "" { + return "", strings.TrimSpace(rest) + } + return NormalizeProvider(protocol), strings.TrimSpace(rest) } // ResolveAPIBase returns the configured API base, or the protocol default when @@ -108,16 +134,16 @@ func ResolveAPIBase(cfg *config.ModelConfig) string { if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" { return strings.TrimRight(apiBase, "/") } - protocol, _ := ExtractProtocol(cfg.Model) + protocol, _ := ExtractProtocol(cfg) return strings.TrimRight(getDefaultAPIBase(protocol), "/") } // CreateProviderFromConfig creates a provider based on the ModelConfig. -// It uses the protocol prefix in the Model field to determine which provider to create. +// It uses ExtractProtocol to determine which provider to create. // Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq), // Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims. // See the switch on protocol in this function for the authoritative list. -// Returns the provider, the model ID (without protocol prefix), and any error. +// Returns the provider, the effective model ID from ExtractProtocol, and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { return nil, "", fmt.Errorf("config is nil") @@ -127,7 +153,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return nil, "", fmt.Errorf("model is required") } - protocol, modelID := ExtractProtocol(cfg.Model) + protocol, modelID := ExtractProtocol(cfg) userAgent := cfg.UserAgent if userAgent == "" { @@ -152,7 +178,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, @@ -161,7 +187,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, cfg.ExtraBody, cfg.CustomHeaders, - ), modelID, nil + ) + provider.SetProviderName(protocol) + return provider, modelID, nil case "azure", "azure-openai": // Azure OpenAI uses deployment-based URLs, api-key header auth, @@ -220,9 +248,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", + "vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", - "coding-plan", "alibaba-coding", "qwen-coding", "mimo": + "coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo": // All other OpenAI-compatible HTTP providers if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -231,7 +259,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, @@ -240,7 +268,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, cfg.ExtraBody, cfg.CustomHeaders, - ), modelID, nil + ) + provider.SetProviderName(protocol) + return provider, modelID, nil case "gemini": if cfg.APIKey() == "" && cfg.APIBase == "" { @@ -276,7 +306,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if _, ok := extraBody["reasoning_split"]; !ok { extraBody["reasoning_split"] = true } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, @@ -285,7 +315,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, extraBody, cfg.CustomHeaders, - ), modelID, nil + ) + provider.SetProviderName(protocol) + return provider, modelID, nil case "anthropic": if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { @@ -304,7 +336,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, @@ -313,7 +345,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.RequestTimeout, cfg.ExtraBody, cfg.CustomHeaders, - ), modelID, nil + ) + provider.SetProviderName(protocol) + return provider, modelID, nil case "anthropic-messages": // Anthropic Messages API with native format (HTTP-based, no SDK) diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 20cdd8a30..3dd1eefb3 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -19,68 +19,103 @@ import ( func TestExtractProtocol(t *testing.T) { tests := []struct { name string - model string + config *config.ModelConfig wantProtocol string wantModelID string }{ { name: "openai with prefix", - model: "openai/gpt-4o", + config: &config.ModelConfig{Model: "openai/gpt-4o"}, wantProtocol: "openai", wantModelID: "gpt-4o", }, { name: "anthropic with prefix", - model: "anthropic/claude-sonnet-4.6", + config: &config.ModelConfig{Model: "anthropic/claude-sonnet-4.6"}, wantProtocol: "anthropic", wantModelID: "claude-sonnet-4.6", }, { name: "no prefix - defaults to openai", - model: "gpt-4o", + config: &config.ModelConfig{Model: "gpt-4o"}, wantProtocol: "openai", wantModelID: "gpt-4o", }, { name: "groq with prefix", - model: "groq/llama-3.1-70b", + config: &config.ModelConfig{Model: "groq/llama-3.1-70b"}, wantProtocol: "groq", wantModelID: "llama-3.1-70b", }, { name: "empty string", - model: "", - wantProtocol: "openai", + config: &config.ModelConfig{Model: ""}, + wantProtocol: "", wantModelID: "", }, { name: "with whitespace", - model: " openai/gpt-4 ", + config: &config.ModelConfig{Model: " openai/gpt-4 "}, wantProtocol: "openai", wantModelID: "gpt-4", }, { name: "multiple slashes", - model: "nvidia/meta/llama-3.1-8b", + config: &config.ModelConfig{Model: "nvidia/meta/llama-3.1-8b"}, wantProtocol: "nvidia", wantModelID: "meta/llama-3.1-8b", }, + { + name: "normalizes provider", + config: &config.ModelConfig{Model: "z.ai/glm-5.1"}, + wantProtocol: "zai", + wantModelID: "glm-5.1", + }, { name: "azure with prefix", - model: "azure/my-gpt5-deployment", + config: &config.ModelConfig{Model: "azure/my-gpt5-deployment"}, wantProtocol: "azure", wantModelID: "my-gpt5-deployment", }, + { + name: "explicit provider keeps model", + config: &config.ModelConfig{Provider: "nvidia", Model: "z-ai/glm-5.1"}, + wantProtocol: "nvidia", + wantModelID: "z-ai/glm-5.1", + }, + { + name: "explicit provider preserves matching prefix", + config: &config.ModelConfig{Provider: "openai", Model: "openai/gpt-4o"}, + wantProtocol: "openai", + wantModelID: "openai/gpt-4o", + }, + { + name: "explicit provider preserves aliased prefix", + config: &config.ModelConfig{Provider: "qwen", Model: "qwen/qwen-plus"}, + wantProtocol: "qwen-portal", + wantModelID: "qwen/qwen-plus", + }, + { + name: "empty provider segment", + config: &config.ModelConfig{Model: "/gpt-4o"}, + wantProtocol: "", + wantModelID: "gpt-4o", + }, + { + name: "nil config", + wantProtocol: "", + wantModelID: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - protocol, modelID := ExtractProtocol(tt.model) + protocol, modelID := ExtractProtocol(tt.config) if protocol != tt.wantProtocol { - t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol) + t.Errorf("ExtractProtocol() protocol = %q, want %q", protocol, tt.wantProtocol) } if modelID != tt.wantModelID { - t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID) + t.Errorf("ExtractProtocol() modelID = %q, want %q", modelID, tt.wantModelID) } }) } @@ -106,6 +141,50 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) { } } +func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-explicit-provider", + Model: "z-ai/glm-5.1", + Provider: "nvidia", + } + 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 != "z-ai/glm-5.1" { + t.Fatalf("modelID = %q, want z-ai/glm-5.1", modelID) + } + if got := ResolveAPIBase(cfg); got != "https://integrate.api.nvidia.com/v1" { + t.Fatalf("ResolveAPIBase() = %q, want NVIDIA default API base", got) + } +} + +func TestCreateProviderFromConfig_PreservesExplicitProviderPrefixedModel(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-openai", + Provider: "openai", + Model: "openai/gpt-4o", + 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 != "openai/gpt-4o" { + t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-4o") + } +} + func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { tests := []struct { name string @@ -701,8 +780,9 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "qwen-max" { - t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + wantModelID := "qwen-max" + if modelID != wantModelID { + t.Errorf("modelID = %q, want %q", modelID, wantModelID) } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) @@ -735,8 +815,9 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "qwen-max" { - t.Errorf("modelID = %q, want %q", modelID, "qwen-max") + wantModelID := "qwen-max" + if modelID != wantModelID { + t.Errorf("modelID = %q, want %q", modelID, wantModelID) } if _, ok := provider.(*HTTPProvider); !ok { t.Fatalf("expected *HTTPProvider, got %T", provider) @@ -769,8 +850,9 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) { if provider == nil { t.Fatal("CreateProviderFromConfig() returned nil provider") } - if modelID != "claude-sonnet-4-20250514" { - t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514") + wantModelID := "claude-sonnet-4-20250514" + if modelID != wantModelID { + t.Errorf("modelID = %q, want %q", modelID, wantModelID) } // coding-plan-anthropic uses Anthropic Messages provider // Verify it's the anthropic messages provider by checking interface diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 54fb9b6ea..07cc01baa 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -268,6 +268,75 @@ func TestFallback_UnclassifiedError(t *testing.T) { } } +func assertFallbackErrorFallsBack( + t *testing.T, + primaryProvider string, + primaryModel string, + initialErr error, + successContent string, + expectedReason FailoverReason, +) { + t.Helper() + + ct := NewCooldownTracker() + fc := NewFallbackChain(ct, nil) + + candidates := []FallbackCandidate{ + makeCandidate(primaryProvider, primaryModel), + makeCandidate("anthropic", "claude"), + } + + attempt := 0 + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + attempt++ + if attempt == 1 { + return nil, initialErr + } + return &LLMResponse{Content: successContent, FinishReason: "stop"}, nil + } + + result, err := fc.Execute(context.Background(), candidates, run) + if err != nil { + t.Fatalf("expected fallback success, got error: %v", err) + } + if attempt != 2 { + t.Fatalf("attempt = %d, want 2", attempt) + } + if result.Provider != "anthropic" || result.Model != "claude" { + t.Fatalf("result = %s/%s, want anthropic/claude", result.Provider, result.Model) + } + if len(result.Attempts) != 1 { + t.Fatalf("attempts = %d, want 1 failed attempt recorded", len(result.Attempts)) + } + if result.Attempts[0].Reason != expectedReason { + t.Fatalf("attempt reason = %q, want %s", result.Attempts[0].Reason, expectedReason) + } +} + +func TestFallback_NetworkErrorFallsBack(t *testing.T) { + assertFallbackErrorFallsBack( + t, + "minimax", + "minimax-m2.7", + errors.New( + `failed to send request: Post "https://opencode.ai/zen/go/v1/chat/completions": tls: bad record MAC`, + ), + "fallback ok", + FailoverNetwork, + ) +} + +func TestFallback_TimeoutErrorFallsBack(t *testing.T) { + assertFallbackErrorFallsBack( + t, + "openai", + "gpt-4", + errors.New("failed to send request: Post \"https://example.com\": i/o timeout"), + "timeout fallback ok", + FailoverTimeout, + ) +} + func TestFallback_SuccessResetsCooldown(t *testing.T) { ct := NewCooldownTracker() fc := NewFallbackChain(ct, nil) diff --git a/pkg/providers/httpapi/gemini_helpers.go b/pkg/providers/httpapi/gemini_helpers.go new file mode 100644 index 000000000..a2b2d63c3 --- /dev/null +++ b/pkg/providers/httpapi/gemini_helpers.go @@ -0,0 +1,82 @@ +package httpapi + +import "strings" + +func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string { + if thoughtSignature != "" { + return thoughtSignature + } + if thoughtSignatureSnake != "" { + return 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, "/") + if !found { + return "openai", model + } + return protocol, modelID +} diff --git a/pkg/providers/gemini_provider.go b/pkg/providers/httpapi/gemini_provider.go similarity index 98% rename from pkg/providers/gemini_provider.go rename to pkg/providers/httpapi/gemini_provider.go index 561387534..d1d523757 100644 --- a/pkg/providers/gemini_provider.go +++ b/pkg/providers/httpapi/gemini_provider.go @@ -1,4 +1,4 @@ -package providers +package httpapi import ( "bufio" @@ -185,7 +185,7 @@ func (p *GeminiProvider) buildRequestBody( case "user": if msg.ToolCallID != "" { - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) contents = append(contents, geminiContent{ Role: "user", Parts: []geminiPart{{ @@ -210,7 +210,7 @@ func (p *GeminiProvider) buildRequestBody( content.Parts = append(content.Parts, geminiPart{Text: msg.Content}) } for _, tc := range msg.ToolCalls { - toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) + toolName, toolArgs, thoughtSignature := common.NormalizeStoredToolCall(tc) if toolName == "" { continue } @@ -234,7 +234,7 @@ func (p *GeminiProvider) buildRequestBody( } case "tool": - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) contents = append(contents, geminiContent{ Role: "user", Parts: []geminiPart{{ @@ -303,7 +303,7 @@ func normalizeGeminiModel(model string) string { model = strings.TrimSpace(model) model = strings.TrimPrefix(model, "models/") if strings.Contains(model, "/") { - _, modelID := ExtractProtocol(model) + _, modelID := extractProtocol(model) if modelID != "" { return modelID } diff --git a/pkg/providers/gemini_provider_test.go b/pkg/providers/httpapi/gemini_provider_test.go similarity index 99% rename from pkg/providers/gemini_provider_test.go rename to pkg/providers/httpapi/gemini_provider_test.go index a0ab748eb..aade90358 100644 --- a/pkg/providers/gemini_provider_test.go +++ b/pkg/providers/httpapi/gemini_provider_test.go @@ -1,4 +1,4 @@ -package providers +package httpapi import ( "encoding/json" diff --git a/pkg/providers/http_provider.go b/pkg/providers/httpapi/http_provider.go similarity index 92% rename from pkg/providers/http_provider.go rename to pkg/providers/httpapi/http_provider.go index ac91f15f6..90f389cc8 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/httpapi/http_provider.go @@ -4,7 +4,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package providers +package httpapi import ( "context" @@ -77,3 +77,10 @@ func (p *HTTPProvider) GetDefaultModel() string { func (p *HTTPProvider) SupportsNativeSearch() bool { return p.delegate.SupportsNativeSearch() } + +func (p *HTTPProvider) SetProviderName(providerName string) { + if p == nil || p.delegate == nil { + return + } + p.delegate.SetProviderName(providerName) +} diff --git a/pkg/providers/httpapi/types.go b/pkg/providers/httpapi/types.go new file mode 100644 index 000000000..c8bcdc0dc --- /dev/null +++ b/pkg/providers/httpapi/types.go @@ -0,0 +1,43 @@ +package httpapi + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra + ContentBlock = protocoltypes.ContentBlock + CacheControl = protocoltypes.CacheControl +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} + +type StreamingProvider interface { + ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), + ) (*LLMResponse, error) +} diff --git a/pkg/providers/httpapi_facade.go b/pkg/providers/httpapi_facade.go new file mode 100644 index 000000000..fea92dc43 --- /dev/null +++ b/pkg/providers/httpapi_facade.go @@ -0,0 +1,46 @@ +package providers + +import httpapi "github.com/sipeed/picoclaw/pkg/providers/httpapi" + +type ( + GeminiProvider = httpapi.GeminiProvider + HTTPProvider = httpapi.HTTPProvider +) + +func NewGeminiProvider( + apiKey string, + apiBase string, + proxy string, + userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *GeminiProvider { + return httpapi.NewGeminiProvider(apiKey, apiBase, proxy, userAgent, requestTimeoutSeconds, extraBody, customHeaders) +} + +func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { + return httpapi.NewHTTPProvider(apiKey, apiBase, proxy) +} + +func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { + return httpapi.NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField) +} + +func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + apiKey, apiBase, proxy, maxTokensField, userAgent string, + requestTimeoutSeconds int, + extraBody map[string]any, + customHeaders map[string]string, +) *HTTPProvider { + return httpapi.NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + apiKey, + apiBase, + proxy, + maxTokensField, + userAgent, + requestTimeoutSeconds, + extraBody, + customHeaders, + ) +} diff --git a/pkg/providers/messageutil/messageutil.go b/pkg/providers/messageutil/messageutil.go new file mode 100644 index 000000000..c4382d894 --- /dev/null +++ b/pkg/providers/messageutil/messageutil.go @@ -0,0 +1,38 @@ +package messageutil + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// IsTransientAssistantThoughtMessage reports whether msg is an invalid +// reasoning-only assistant history record. These "hanging" thought messages +// are not a canonical persisted format and should be discarded instead of +// replayed or reconstructed. +func IsTransientAssistantThoughtMessage(msg protocoltypes.Message) bool { + return msg.Role == "assistant" && + strings.TrimSpace(msg.Content) == "" && + strings.TrimSpace(msg.ReasoningContent) != "" && + len(msg.ToolCalls) == 0 && + len(msg.Media) == 0 && + len(msg.Attachments) == 0 && + strings.TrimSpace(msg.ToolCallID) == "" +} + +// FilterInvalidHistoryMessages removes invalid persisted history records such +// as transient assistant thought-only messages. +func FilterInvalidHistoryMessages(history []protocoltypes.Message) []protocoltypes.Message { + if len(history) == 0 { + return []protocoltypes.Message{} + } + + filtered := make([]protocoltypes.Message, 0, len(history)) + for _, msg := range history { + if IsTransientAssistantThoughtMessage(msg) { + continue + } + filtered = append(filtered, msg) + } + return filtered +} diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/oauth/antigravity_provider.go similarity index 93% rename from pkg/providers/antigravity_provider.go rename to pkg/providers/oauth/antigravity_provider.go index b5ab847d5..1ac2d9c7f 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/oauth/antigravity_provider.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "bufio" @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers/common" ) const ( @@ -221,7 +222,7 @@ func (p *AntigravityProvider) buildRequest( } case "user": if msg.ToolCallID != "" { - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) // Tool result req.Contents = append(req.Contents, antigravityContent{ Role: "user", @@ -248,7 +249,7 @@ func (p *AntigravityProvider) buildRequest( content.Parts = append(content.Parts, antigravityPart{Text: msg.Content}) } for _, tc := range msg.ToolCalls { - toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) + toolName, toolArgs, thoughtSignature := common.NormalizeStoredToolCall(tc) if toolName == "" { logger.WarnCF( "provider.antigravity", @@ -275,7 +276,7 @@ func (p *AntigravityProvider) buildRequest( req.Contents = append(req.Contents, content) } case "tool": - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) req.Contents = append(req.Contents, antigravityContent{ Role: "user", Parts: []antigravityPart{{ @@ -328,60 +329,6 @@ func (p *AntigravityProvider) buildRequest( return req } -func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { - name := tc.Name - args := tc.Arguments - thoughtSignature := "" - - if name == "" && tc.Function != nil { - name = tc.Function.Name - thoughtSignature = tc.Function.ThoughtSignature - } else if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - - if args == nil { - args = map[string]any{} - } - - if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { - var parsed map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { - args = parsed - } - } - - return name, args, thoughtSignature -} - -func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { - if toolCallID == "" { - return "" - } - - if name, ok := toolCallNames[toolCallID]; ok && name != "" { - return name - } - - return inferToolNameFromCallID(toolCallID) -} - -func inferToolNameFromCallID(toolCallID string) string { - if !strings.HasPrefix(toolCallID, "call_") { - return toolCallID - } - - rest := strings.TrimPrefix(toolCallID, "call_") - if idx := strings.LastIndex(rest, "_"); idx > 0 { - candidate := rest[:idx] - if candidate != "" { - return candidate - } - } - - return toolCallID -} - // --- Response parsing --- type antigravityJSONResponse struct { diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/oauth/antigravity_provider_test.go similarity index 89% rename from pkg/providers/antigravity_provider_test.go rename to pkg/providers/oauth/antigravity_provider_test.go index 9155e2d56..2989f8519 100644 --- a/pkg/providers/antigravity_provider_test.go +++ b/pkg/providers/oauth/antigravity_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import "testing" @@ -48,13 +48,6 @@ func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { } } -func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) { - got := resolveToolResponseName("call_search_docs_999", map[string]string{}) - if got != "search_docs" { - t.Fatalf("expected inferred tool name search_docs, got %q", got) - } -} - func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) { p := &AntigravityProvider{} body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" + diff --git a/pkg/providers/claude_provider.go b/pkg/providers/oauth/claude_provider.go similarity index 91% rename from pkg/providers/claude_provider.go rename to pkg/providers/oauth/claude_provider.go index 60639ca18..cf0052acd 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/oauth/claude_provider.go @@ -1,9 +1,10 @@ -package providers +package oauthprovider import ( "context" "fmt" + "github.com/sipeed/picoclaw/pkg/auth" anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) @@ -55,7 +56,7 @@ func (p *ClaudeProvider) GetDefaultModel() string { return p.delegate.GetDefaultModel() } -func createClaudeTokenSource() func() (string, error) { +func CreateClaudeTokenSource(getCredential func(string) (*auth.AuthCredential, error)) func() (string, error) { return func() (string, error) { cred, err := getCredential("anthropic") if err != nil { diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/oauth/claude_provider_test.go similarity index 99% rename from pkg/providers/claude_provider_test.go rename to pkg/providers/oauth/claude_provider_test.go index 98e07bb80..eea5423c3 100644 --- a/pkg/providers/claude_provider_test.go +++ b/pkg/providers/oauth/claude_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "encoding/json" diff --git a/pkg/providers/codex_provider.go b/pkg/providers/oauth/codex_provider.go similarity index 98% rename from pkg/providers/codex_provider.go rename to pkg/providers/oauth/codex_provider.go index d968215cc..0b125997b 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "context" @@ -240,7 +240,7 @@ func buildCodexParams( return params } -func createCodexTokenSource() func() (string, string, error) { +func CreateCodexTokenSource() func() (string, string, error) { return func() (string, string, error) { cred, err := auth.GetCredential("openai") if err != nil { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go similarity index 99% rename from pkg/providers/codex_provider_test.go rename to pkg/providers/oauth/codex_provider_test.go index ad5748e0c..aeeb18360 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -1,4 +1,4 @@ -package providers +package oauthprovider import ( "encoding/json" diff --git a/pkg/providers/oauth/types.go b/pkg/providers/oauth/types.go new file mode 100644 index 000000000..02ea4a21c --- /dev/null +++ b/pkg/providers/oauth/types.go @@ -0,0 +1,32 @@ +package oauthprovider + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +type ( + ToolCall = protocoltypes.ToolCall + FunctionCall = protocoltypes.FunctionCall + LLMResponse = protocoltypes.LLMResponse + UsageInfo = protocoltypes.UsageInfo + Message = protocoltypes.Message + ToolDefinition = protocoltypes.ToolDefinition + ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition + ExtraContent = protocoltypes.ExtraContent + GoogleExtra = protocoltypes.GoogleExtra + ContentBlock = protocoltypes.ContentBlock + CacheControl = protocoltypes.CacheControl +) + +type LLMProvider interface { + Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + ) (*LLMResponse, error) + GetDefaultModel() string +} diff --git a/pkg/providers/oauth_facade.go b/pkg/providers/oauth_facade.go new file mode 100644 index 000000000..c14117773 --- /dev/null +++ b/pkg/providers/oauth_facade.go @@ -0,0 +1,60 @@ +package providers + +import ( + oauthprovider "github.com/sipeed/picoclaw/pkg/providers/oauth" +) + +type ( + AntigravityProvider = oauthprovider.AntigravityProvider + AntigravityModelInfo = oauthprovider.AntigravityModelInfo + ClaudeProvider = oauthprovider.ClaudeProvider + CodexProvider = oauthprovider.CodexProvider +) + +func NewAntigravityProvider() *AntigravityProvider { + return oauthprovider.NewAntigravityProvider() +} + +func NewClaudeProvider(token string) *ClaudeProvider { + return oauthprovider.NewClaudeProvider(token) +} + +func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithBaseURL(token, apiBase) +} + +func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithTokenSource(token, tokenSource) +} + +func NewClaudeProviderWithTokenSourceAndBaseURL( + token string, tokenSource func() (string, error), apiBase string, +) *ClaudeProvider { + return oauthprovider.NewClaudeProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase) +} + +func NewCodexProvider(token, accountID string) *CodexProvider { + return oauthprovider.NewCodexProvider(token, accountID) +} + +func NewCodexProviderWithTokenSource( + token, accountID string, tokenSource func() (string, string, error), +) *CodexProvider { + return oauthprovider.NewCodexProviderWithTokenSource(token, accountID, tokenSource) +} + +func FetchAntigravityProjectID(accessToken string) (string, error) { + return oauthprovider.FetchAntigravityProjectID(accessToken) +} + +func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) { + return oauthprovider.FetchAntigravityModels(accessToken, projectID) +} + +func createClaudeTokenSource() func() (string, error) { + return oauthprovider.CreateClaudeTokenSource(getCredential) +} + +func createCodexTokenSource() func() (string, string, error) { + return oauthprovider.CreateCodexTokenSource() +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 98a70cfd2..c3733ce3a 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -15,6 +15,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -34,6 +35,7 @@ type ( type Provider struct { apiKey string apiBase string + providerName string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client extraBody map[string]any // Additional fields to inject into request body @@ -95,6 +97,12 @@ func WithCustomHeaders(customHeaders map[string]string) Option { } } +func WithProviderName(providerName string) Option { + return func(p *Provider) { + p.providerName = strings.ToLower(strings.TrimSpace(providerName)) + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, @@ -136,7 +144,7 @@ func (p *Provider) buildRequestBody( requestBody := map[string]any{ "model": model, - "messages": common.SerializeMessages(messages), + "messages": common.SerializeMessages(p.prepareMessagesForRequest(messages)), } // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. @@ -196,6 +204,111 @@ func (p *Provider) applyCustomHeaders(req *http.Request) { } } +func (p *Provider) SetProviderName(providerName string) { + p.providerName = strings.ToLower(strings.TrimSpace(providerName)) +} + +func (p *Provider) prepareMessagesForRequest(messages []Message) []Message { + if len(messages) == 0 { + return nil + } + + if p.isDeepSeekReasoningProvider() { + return filterDeepSeekReasoningMessages(messages) + } + return stripReasoningMessages(messages) +} + +func (p *Provider) isDeepSeekReasoningProvider() bool { + return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase) +} + +func isDeepSeekHost(apiBase string) bool { + parsed, err := url.Parse(strings.TrimSpace(apiBase)) + if err != nil { + return false + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + return host == "deepseek.com" || strings.HasSuffix(host, ".deepseek.com") +} + +func filterDeepSeekReasoningMessages(messages []Message) []Message { + out := make([]Message, 0, len(messages)) + start := 0 + + flush := func(end int) { + if end <= start { + return + } + out = append(out, filterDeepSeekReasoningTurn(messages[start:end])...) + start = end + } + + for i := 1; i < len(messages); i++ { + if messages[i].Role == "user" { + flush(i) + } + } + flush(len(messages)) + + return out +} + +func filterDeepSeekReasoningTurn(messages []Message) []Message { + hasToolInteraction := false + for _, msg := range messages { + if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) { + hasToolInteraction = true + break + } + } + + out := make([]Message, 0, len(messages)) + for _, msg := range messages { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + + cloned := msg + if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction { + cloned.ReasoningContent = "" + } + if assistantMessageEmpty(cloned) { + continue + } + out = append(out, cloned) + } + + return out +} + +func stripReasoningMessages(messages []Message) []Message { + out := make([]Message, 0, len(messages)) + for _, msg := range messages { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + + cloned := msg + cloned.ReasoningContent = "" + if assistantMessageEmpty(cloned) { + continue + } + out = append(out, cloned) + } + return out +} + +func assistantMessageEmpty(msg Message) bool { + return msg.Role == "assistant" && + strings.TrimSpace(msg.Content) == "" && + strings.TrimSpace(msg.ReasoningContent) == "" && + len(msg.ToolCalls) == 0 && + len(msg.Media) == 0 && + len(msg.Attachments) == 0 && + strings.TrimSpace(msg.ToolCallID) == "" +} + func (p *Provider) Chat( ctx context.Context, messages []Message, @@ -470,7 +583,9 @@ func (p *Provider) SupportsNativeSearch() bool { return isNativeSearchHost(p.apiBase) } -func isNativeSearchHost(apiBase string) bool { +// isNativeOpenAIOrAzureEndpoint reports whether the given API base points to +// OpenAI's own API or an Azure OpenAI deployment. +func isNativeOpenAIOrAzureEndpoint(apiBase string) bool { u, err := url.Parse(apiBase) if err != nil { return false @@ -479,15 +594,14 @@ func isNativeSearchHost(apiBase string) bool { return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") } +func isNativeSearchHost(apiBase string) bool { + return isNativeOpenAIOrAzureEndpoint(apiBase) +} + // supportsPromptCacheKey reports whether the given API base is known to // support the prompt_cache_key request field. Currently only OpenAI's own // API and Azure OpenAI support this. All other OpenAI-compatible providers // (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors. func supportsPromptCacheKey(apiBase string) bool { - u, err := url.Parse(apiBase) - if err != nil { - return false - } - host := u.Hostname() - return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") + return isNativeOpenAIOrAzureEndpoint(apiBase) } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index d140d63d6..594048ea5 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -202,7 +202,7 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) { } } -func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { +func TestProviderChat_StripsReasoningContentForNonDeepSeekHistory(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -225,8 +225,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { p := NewProvider("key", server.URL, "") - // Simulate a multi-turn conversation where the assistant's previous - // reply included reasoning_content (e.g. from kimi-k2.5). messages := []Message{ {Role: "user", Content: "What is 1+1?"}, {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, @@ -238,7 +236,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { t.Fatalf("Chat() error = %v", err) } - // Verify reasoning_content is preserved in the serialized request. reqMessages, ok := requestBody["messages"].([]any) if !ok { t.Fatalf("messages is not []any: %T", requestBody["messages"]) @@ -247,11 +244,288 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { if !ok { t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) } - if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" { - t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"]) + if _, exists := assistantMsg["reasoning_content"]; exists { + t.Fatalf( + "reasoning_content should be stripped for non-DeepSeek providers, got %v", + assistantMsg["reasoning_content"], + ) } } +func TestProviderChat_DeepSeekOmitsReasoningContentForNonToolTurnHistory(t *testing.T) { + 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.apiBase = "https://api.deepseek.com/v1" + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + + messages := []Message{ + {Role: "user", Content: "What is 1+1?"}, + {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, + {Role: "user", Content: "What about 2+2?"}, + } + + _, 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"]) + } + assistantMsg, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) + } + if _, exists := assistantMsg["reasoning_content"]; exists { + t.Fatalf( + "reasoning_content should be omitted for DeepSeek non-tool turns, got %v", + assistantMsg["reasoning_content"], + ) + } +} + +func TestProviderChat_DeepSeekPreservesReasoningContentForToolTurnHistory(t *testing.T) { + 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: "How'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_1", + Type: "function", + Function: &FunctionCall{ + Name: "get_date", + Arguments: "{}", + }, + }}, + }, + {Role: "tool", ToolCallID: "call_1", Content: "2026-04-24"}, + { + Role: "assistant", + Content: "Tomorrow is 2026-04-25.", + ReasoningContent: "Now I can share the final answer.", + }, + {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)) + } + + firstAssistant, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("first assistant message is not map[string]any: %T", reqMessages[1]) + } + if firstAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." { + t.Fatalf("first assistant reasoning_content = %v, want preserved", firstAssistant["reasoning_content"]) + } + + finalAssistant, ok := reqMessages[3].(map[string]any) + if !ok { + t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[3]) + } + if finalAssistant["reasoning_content"] != "Now I can share the final answer." { + t.Fatalf("final assistant reasoning_content = %v, want preserved", finalAssistant["reasoning_content"]) + } +} + +func TestProviderChat_HistoryCanonicalizationMatrix(t *testing.T) { + baseMessages := []Message{ + {Role: "user", Content: "turn1"}, + {Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"}, + {Role: "user", Content: "turn2"}, + { + Role: "assistant", + Content: "", + ReasoningContent: "tool thought", + ToolCalls: []ToolCall{{ + ID: "call_read_file", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_read_file", Content: "file content"}, + {Role: "user", Content: "turn3"}, + { + Role: "assistant", + Content: "tool visible only", + ToolCalls: []ToolCall{{ + ID: "call_list_dir", + Type: "function", + Function: &FunctionCall{ + Name: "list_dir", + Arguments: `{"path":"."}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_list_dir", Content: "dir listing"}, + {Role: "user", Content: "turn4"}, + { + Role: "assistant", + Content: "tool visible and thought", + ReasoningContent: "tool mixed thought", + ToolCalls: []ToolCall{{ + ID: "call_exec", + Type: "function", + Function: &FunctionCall{ + Name: "exec", + Arguments: `{"command":"pwd"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_exec", Content: "pwd output"}, + {Role: "user", Content: "current turn"}, + } + + captureRequestMessages := func(t *testing.T, providerName string) []map[string]any { + t.Helper() + + 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, "") + if providerName != "" { + p.SetProviderName(providerName) + } + + _, err := p.Chat(t.Context(), baseMessages, nil, "test-model", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + rawMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + + out := make([]map[string]any, 0, len(rawMessages)) + for i, raw := range rawMessages { + msg, ok := raw.(map[string]any) + if !ok { + t.Fatalf("messages[%d] is %T, want map[string]any", i, raw) + } + out = append(out, msg) + } + return out + } + + t.Run("deepseek", func(t *testing.T) { + msgs := captureRequestMessages(t, "deepseek") + if len(msgs) != len(baseMessages) { + t.Fatalf("len(messages) = %d, want %d", len(msgs), len(baseMessages)) + } + + if _, ok := msgs[1]["reasoning_content"]; ok { + t.Fatalf( + "turn1 reasoning_content should be stripped for DeepSeek non-tool turn, got %v", + msgs[1]["reasoning_content"], + ) + } + if msgs[3]["reasoning_content"] != "tool thought" { + t.Fatalf("turn2 reasoning_content = %v, want preserved", msgs[3]["reasoning_content"]) + } + if _, ok := msgs[6]["reasoning_content"]; ok { + t.Fatalf("turn3 reasoning_content should be absent, got %v", msgs[6]["reasoning_content"]) + } + if msgs[9]["reasoning_content"] != "tool mixed thought" { + t.Fatalf("turn4 reasoning_content = %v, want preserved", msgs[9]["reasoning_content"]) + } + if msgs[9]["content"] != "tool visible and thought" { + t.Fatalf("turn4 content = %v, want preserved", msgs[9]["content"]) + } + }) + + t.Run("non-deepseek", func(t *testing.T) { + msgs := captureRequestMessages(t, "") + for i, msg := range msgs { + if _, ok := msg["reasoning_content"]; ok { + t.Fatalf( + "messages[%d] reasoning_content should be stripped for non-DeepSeek providers, got %v", + i, + msg["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/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go index 839471f69..17b731ed4 100644 --- a/pkg/providers/openai_responses_common/responses_common.go +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -10,6 +10,7 @@ import ( "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/responses" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -118,7 +119,7 @@ func BuildMultipartContent(text string, media []string) responses.ResponseInputM }, }) } else if strings.HasPrefix(mediaURL, "data:audio/") { - if format, data, ok := ParseDataAudioURL(mediaURL); ok { + if format, data, ok := common.ParseDataAudioURL(mediaURL); ok { parts = append(parts, responses.ResponseInputContentUnionParam{ OfInputFile: &responses.ResponseInputFileParam{ FileData: openai.Opt(data), @@ -132,25 +133,6 @@ func BuildMultipartContent(text string, media []string) responses.ResponseInputM return parts } -// ParseDataAudioURL extracts the format and base64 data from a data:audio/... URL. -func ParseDataAudioURL(mediaURL string) (format, data string, ok bool) { - if !strings.HasPrefix(mediaURL, "data:audio/") { - return "", "", false - } - payload := strings.TrimPrefix(mediaURL, "data:audio/") - meta, data, found := strings.Cut(payload, ",") - if !found { - return "", "", false - } - format, _, _ = strings.Cut(meta, ";") - format = strings.TrimSpace(format) - data = strings.TrimSpace(data) - if format == "" || data == "" { - return "", "", false - } - return format, data, true -} - // ResolveToolCall extracts the function name and JSON arguments string from a ToolCall. // Returns ok=false if the tool call has no name or if arguments fail to marshal. func ResolveToolCall(tc protocoltypes.ToolCall) (name string, arguments string, ok bool) { diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go index 0d41190b1..ace91edf0 100644 --- a/pkg/providers/openai_responses_common/responses_common_test.go +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -506,42 +506,6 @@ func TestParseResponseBody_CanceledStatus(t *testing.T) { } } -// --- ParseDataAudioURL tests --- - -func TestParseDataAudioURL_Valid(t *testing.T) { - format, data, ok := ParseDataAudioURL("data:audio/mp3;base64,SGVsbG8=") - if !ok { - t.Fatal("expected ok=true") - } - if format != "mp3" { - t.Errorf("format = %q, want %q", format, "mp3") - } - if data != "SGVsbG8=" { - t.Errorf("data = %q, want %q", data, "SGVsbG8=") - } -} - -func TestParseDataAudioURL_NotAudio(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:image/png;base64,abc") - if ok { - t.Error("expected ok=false for non-audio URL") - } -} - -func TestParseDataAudioURL_MalformedNoComma(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:audio/mp3;base64") - if ok { - t.Error("expected ok=false for malformed URL") - } -} - -func TestParseDataAudioURL_EmptyData(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:audio/mp3;base64,") - if ok { - t.Error("expected ok=false for empty data") - } -} - // --- BuildMultipartContent tests --- func TestBuildMultipartContent_TextOnly(t *testing.T) { diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 194c1aa6f..bab4433e7 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -11,7 +11,8 @@ type ToolCall struct { } type ExtraContent struct { - Google *GoogleExtra `json:"google,omitempty"` + Google *GoogleExtra `json:"google,omitempty"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` } type GoogleExtra struct { @@ -60,21 +61,50 @@ type ContentBlock struct { Type string `json:"type"` // "text" Text string `json:"text"` CacheControl *CacheControl `json:"cache_control,omitempty"` + + // Prompt metadata is internal to the agent runtime. It records which + // structured prompt segment produced this block without changing provider + // JSON. + PromptLayer string `json:"-"` + PromptSlot string `json:"-"` + PromptSource string `json:"-"` +} + +type Attachment struct { + Type string `json:"type,omitempty"` + Ref string `json:"ref,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` } type Message struct { Role string `json:"role"` Content string `json:"content"` Media []string `json:"media,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` + + // Prompt metadata is internal to the agent runtime. It records where a + // message or system part came from without changing provider/session JSON. + PromptLayer string `json:"-"` + PromptSlot string `json:"-"` + PromptSource string `json:"-"` } type ToolDefinition struct { Type string `json:"type"` Function ToolFunctionDefinition `json:"function"` + + // Prompt metadata is internal to the agent runtime. Tool definitions are + // model-visible capability prompts even though providers send them outside + // the system message. + PromptLayer string `json:"-"` + PromptSlot string `json:"-"` + PromptSource string `json:"-"` } type ToolFunctionDefinition struct { diff --git a/pkg/providers/toolcall_utils_test.go b/pkg/providers/toolcall_utils_test.go new file mode 100644 index 000000000..a4bb03c2e --- /dev/null +++ b/pkg/providers/toolcall_utils_test.go @@ -0,0 +1,24 @@ +package providers + +import "testing" + +func TestNormalizeToolCall_PreservesExtraContentGoogleThoughtSignature(t *testing.T) { + tc := NormalizeToolCall(ToolCall{ + ID: "call_1", + Name: "search", + Arguments: map[string]any{"q": "pico"}, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }) + + if tc.ThoughtSignature != "sig-1" { + t.Fatalf("ThoughtSignature = %q, want sig-1", tc.ThoughtSignature) + } + if tc.Function == nil { + t.Fatal("Function is nil") + } + if tc.Function.ThoughtSignature != "sig-1" { + t.Fatalf("Function.ThoughtSignature = %q, want sig-1", tc.Function.ThoughtSignature) + } +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index f98ae9243..23406bc45 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -19,6 +19,7 @@ type ( GoogleExtra = protocoltypes.GoogleExtra ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl + Attachment = protocoltypes.Attachment ) type LLMProvider interface { @@ -74,6 +75,7 @@ const ( FailoverAuth FailoverReason = "auth" FailoverRateLimit FailoverReason = "rate_limit" FailoverBilling FailoverReason = "billing" + FailoverNetwork FailoverReason = "network" FailoverTimeout FailoverReason = "timeout" FailoverFormat FailoverReason = "format" FailoverContextOverflow FailoverReason = "context_overflow" diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 7f87d460a..1d6fa3106 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" ) type Session struct { @@ -69,6 +70,10 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) { // AddFullMessage adds a complete message with tool calls and tool call ID to the session. // This is used to save the full conversation flow including tool calls and tool results. func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return + } + sm.mu.Lock() defer sm.mu.Unlock() @@ -196,8 +201,7 @@ func (sm *SessionManager) Save(key string) error { Updated: stored.Updated, } if len(stored.Messages) > 0 { - snapshot.Messages = make([]providers.Message, len(stored.Messages)) - copy(snapshot.Messages, stored.Messages) + snapshot.Messages = messageutil.FilterInvalidHistoryMessages(stored.Messages) } else { snapshot.Messages = []providers.Message{} } @@ -270,6 +274,7 @@ func (sm *SessionManager) loadSessions() error { if err := json.Unmarshal(data, &session); err != nil { continue } + session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages) sm.sessions[session.Key] = &session } @@ -290,6 +295,7 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) { session, ok := sm.sessions[key] if ok { + history = messageutil.FilterInvalidHistoryMessages(history) // Create a deep copy to strictly isolate internal state // from the caller's slice. msgs := make([]providers.Message, len(history)) diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 30a8e92cd..f2e6561df 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -6,6 +6,8 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" @@ -18,7 +20,7 @@ type JobExecutor interface { ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) // PublishResponseIfNeeded sends response to the outbound bus only when the // agent did not already deliver content through the message tool in this round. - PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) + PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) } // CronTool provides scheduling capabilities for the agent @@ -340,7 +342,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { return "ok" } - sessionKey := fmt.Sprintf("cron-%s", job.ID) + sessionKey := fmt.Sprintf("agent:cron-%s-%s", job.ID, uuid.New().String()) // Call agent with the job message response, err := t.executor.ProcessDirectWithChannel( @@ -355,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, "", response) } return "ok" } diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index c699908cd..d46d365a0 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -39,7 +39,7 @@ func (s *stubJobExecutor) ProcessDirectWithChannel( func (s *stubJobExecutor) PublishResponseIfNeeded( _ context.Context, - channel, chatID, response string, + channel, chatID, sessionKey, response string, ) { if s.alreadySent { return @@ -271,8 +271,8 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { t.Fatalf("ExecuteJob() = %q, want ok", got) } - if executor.lastKey != "cron-job-1" { - t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey) + if !strings.HasPrefix(executor.lastKey, "agent:cron-job-1-") { + t.Fatalf("sessionKey = %q, want agent:cron-job-1-{uuid}", executor.lastKey) } if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" { t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID) diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go new file mode 100644 index 000000000..672554209 --- /dev/null +++ b/pkg/tools/facade_compat_test.go @@ -0,0 +1,15 @@ +package tools + +import "testing" + +func TestFacadeConstructorsRemainAvailable(t *testing.T) { + if NewI2CTool() == nil { + t.Fatal("NewI2CTool should return a tool") + } + if NewSPITool() == nil { + t.Fatal("NewSPITool should return a tool") + } + if NewMessageTool() == nil { + t.Fatal("NewMessageTool should return a tool") + } +} diff --git a/pkg/tools/edit.go b/pkg/tools/fs/edit.go similarity index 99% rename from pkg/tools/edit.go rename to pkg/tools/fs/edit.go index c527dab54..827ea50c8 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/fs/edit.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/edit_test.go b/pkg/tools/fs/edit_test.go similarity index 99% rename from pkg/tools/edit_test.go rename to pkg/tools/fs/edit_test.go index 83a7e778c..4c25322ef 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/fs/edit_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/filesystem.go b/pkg/tools/fs/filesystem.go similarity index 99% rename from pkg/tools/filesystem.go rename to pkg/tools/fs/filesystem.go index 0f6811f33..262d88d99 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/fs/filesystem.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "bufio" @@ -24,6 +24,18 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow +func ValidatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return validatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func IsAllowedPath(path string, patterns []*regexp.Regexp) bool { + return isAllowedPath(path, patterns) +} + func validatePathWithAllowPaths( path, workspace string, restrict bool, diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/fs/filesystem_test.go similarity index 96% rename from pkg/tools/filesystem_test.go rename to pkg/tools/fs/filesystem_test.go index 0ab37c215..4387332be 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/fs/filesystem_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -1050,43 +1050,6 @@ func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) { } } -func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "registry_lines.txt") - - err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) - if err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - reg := NewToolRegistry() - reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) - - result := reg.Execute(context.Background(), "read_file", map[string]any{ - "path": testFile, - "start_line": 1, - "max_lines": 1, - }) - if result.IsError { - t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "1|line 1\n") { - t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) - } - - result = reg.Execute(context.Background(), "read_file", map[string]any{ - "path": testFile, - "start_line": 2, - "limit": 1, - }) - if !result.IsError { - t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) - } - if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { - t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) - } -} - func TestReadFileLinesTool_RejectsOffset(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "legacy_offset.txt") diff --git a/pkg/tools/load_image.go b/pkg/tools/fs/load_image.go similarity index 99% rename from pkg/tools/load_image.go rename to pkg/tools/fs/load_image.go index 41ea6d054..6f612faea 100644 --- a/pkg/tools/load_image.go +++ b/pkg/tools/fs/load_image.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/load_image_test.go b/pkg/tools/fs/load_image_test.go similarity index 90% rename from pkg/tools/load_image_test.go rename to pkg/tools/fs/load_image_test.go index 91118f93e..72f163d81 100644 --- a/pkg/tools/load_image_test.go +++ b/pkg/tools/fs/load_image_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" @@ -9,7 +9,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/providers" ) func TestLoadImage_PathRequired(t *testing.T) { @@ -78,28 +77,6 @@ func TestLoadImage_FileTooLarge(t *testing.T) { } } -func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { - manager := NewSubagentManager(nil, "gpt-test", "/tmp") - - called := false - manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { - called = true - return msgs - }) - - manager.mu.RLock() - got := manager.mediaResolver - manager.mu.RUnlock() - - if got == nil { - t.Fatal("expected mediaResolver to be set") - } - - if called { - t.Fatal("resolver should not be called during SetMediaResolver") - } -} - func TestLoadImage_SuccessPath(t *testing.T) { dir := t.TempDir() diff --git a/pkg/tools/send_file.go b/pkg/tools/fs/send_file.go similarity index 99% rename from pkg/tools/send_file.go rename to pkg/tools/fs/send_file.go index 44198381e..e4f90bf61 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/fs/send_file.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/send_file_test.go b/pkg/tools/fs/send_file_test.go similarity index 99% rename from pkg/tools/send_file_test.go rename to pkg/tools/fs/send_file_test.go index f36baf7d0..771393b75 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/fs/send_file_test.go @@ -1,4 +1,4 @@ -package tools +package fstools import ( "context" diff --git a/pkg/tools/fs/shared.go b/pkg/tools/fs/shared.go new file mode 100644 index 000000000..6d46e692b --- /dev/null +++ b/pkg/tools/fs/shared.go @@ -0,0 +1,37 @@ +package fstools + +import ( + "context" + + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ToolResult = toolshared.ToolResult + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/fs_facade.go b/pkg/tools/fs_facade.go new file mode 100644 index 000000000..5ed68f04c --- /dev/null +++ b/pkg/tools/fs_facade.go @@ -0,0 +1,100 @@ +package tools + +import ( + "regexp" + + "github.com/sipeed/picoclaw/pkg/media" + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +type ( + ReadFileTool = fstools.ReadFileTool + ReadFileLinesTool = fstools.ReadFileLinesTool + WriteFileTool = fstools.WriteFileTool + ListDirTool = fstools.ListDirTool + EditFileTool = fstools.EditFileTool + AppendFileTool = fstools.AppendFileTool + LoadImageTool = fstools.LoadImageTool + SendFileTool = fstools.SendFileTool +) + +const MaxReadFileSize = fstools.MaxReadFileSize + +func NewReadFileTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return fstools.NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileBytesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return fstools.NewReadFileBytesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileLinesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileLinesTool { + return fstools.NewReadFileLinesTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { + return fstools.NewWriteFileTool(workspace, restrict, allowPaths...) +} + +func NewListDirTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *ListDirTool { + return fstools.NewListDirTool(workspace, restrict, allowPaths...) +} + +func NewEditFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *EditFileTool { + return fstools.NewEditFileTool(workspace, restrict, allowPaths...) +} + +func NewAppendFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *AppendFileTool { + return fstools.NewAppendFileTool(workspace, restrict, allowPaths...) +} + +func NewLoadImageTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *LoadImageTool { + return fstools.NewLoadImageTool(workspace, restrict, maxFileSize, store, allowPaths...) +} + +func NewSendFileTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *SendFileTool { + return fstools.NewSendFileTool(workspace, restrict, maxFileSize, store, allowPaths...) +} diff --git a/pkg/tools/fs_registry_compat_test.go b/pkg/tools/fs_registry_compat_test.go new file mode 100644 index 000000000..51e080217 --- /dev/null +++ b/pkg/tools/fs_registry_compat_test.go @@ -0,0 +1,46 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "registry_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + reg := NewToolRegistry() + reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) + + result := reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 1, + }) + if result.IsError { + t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n") { + t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) + } + + result = reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 2, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { + t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/i2c.go b/pkg/tools/hardware/i2c.go similarity index 97% rename from pkg/tools/i2c.go rename to pkg/tools/hardware/i2c.go index 779b1d5a7..62e9557ee 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/hardware/i2c.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" @@ -120,16 +120,12 @@ func (t *I2CTool) detect() *ToolResult { // Helper functions for I2C operations (used by platform-specific implementations) // isValidBusID checks that a bus identifier is a simple number (prevents path injection) -// -//nolint:unused // Used by i2c_linux.go func isValidBusID(id string) bool { matched, _ := regexp.MatchString(`^\d+$`, id) return matched } // parseI2CAddress extracts and validates an I2C address from args -// -//nolint:unused // Used by i2c_linux.go func parseI2CAddress(args map[string]any) (int, *ToolResult) { addrFloat, ok := args["address"].(float64) if !ok { @@ -143,8 +139,6 @@ func parseI2CAddress(args map[string]any) (int, *ToolResult) { } // parseI2CBus extracts and validates an I2C bus from args -// -//nolint:unused // Used by i2c_linux.go func parseI2CBus(args map[string]any) (string, *ToolResult) { bus, ok := args["bus"].(string) if !ok || bus == "" { @@ -155,3 +149,9 @@ func parseI2CBus(args map[string]any) (string, *ToolResult) { } return bus, nil } + +var ( + _ = isValidBusID + _ = parseI2CAddress + _ = parseI2CBus +) diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/hardware/i2c_linux.go similarity index 99% rename from pkg/tools/i2c_linux.go rename to pkg/tools/hardware/i2c_linux.go index 4eaaf8f09..771d11d90 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/hardware/i2c_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/i2c_other.go b/pkg/tools/hardware/i2c_other.go similarity index 95% rename from pkg/tools/i2c_other.go rename to pkg/tools/hardware/i2c_other.go index 7becf8339..4a0a130e0 100644 --- a/pkg/tools/i2c_other.go +++ b/pkg/tools/hardware/i2c_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // scan is a stub for non-Linux platforms. func (t *I2CTool) scan(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware/shared.go b/pkg/tools/hardware/shared.go new file mode 100644 index 000000000..3012f3e6c --- /dev/null +++ b/pkg/tools/hardware/shared.go @@ -0,0 +1,13 @@ +package hardwaretools + +import toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" + +type ToolResult = toolshared.ToolResult + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} diff --git a/pkg/tools/spi.go b/pkg/tools/hardware/spi.go similarity index 98% rename from pkg/tools/spi.go rename to pkg/tools/hardware/spi.go index 0ca17e84f..0bc0d8f72 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/hardware/spi.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "context" @@ -122,8 +122,6 @@ func (t *SPITool) list() *ToolResult { // Helper function for SPI operations (used by platform-specific implementations) // parseSPIArgs extracts and validates common SPI parameters -// -//nolint:unused // Used by spi_linux.go func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) if !ok || dev == "" { @@ -160,3 +158,5 @@ func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, return dev, speed, mode, bits, "" } + +var _ = parseSPIArgs diff --git a/pkg/tools/spi_linux.go b/pkg/tools/hardware/spi_linux.go similarity index 99% rename from pkg/tools/spi_linux.go rename to pkg/tools/hardware/spi_linux.go index 9def73662..8502d6b9e 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/hardware/spi_linux.go @@ -1,4 +1,4 @@ -package tools +package hardwaretools import ( "encoding/json" diff --git a/pkg/tools/spi_other.go b/pkg/tools/hardware/spi_other.go similarity index 94% rename from pkg/tools/spi_other.go rename to pkg/tools/hardware/spi_other.go index 5d078ac3f..89fc99e67 100644 --- a/pkg/tools/spi_other.go +++ b/pkg/tools/hardware/spi_other.go @@ -1,6 +1,6 @@ //go:build !linux -package tools +package hardwaretools // transfer is a stub for non-Linux platforms. func (t *SPITool) transfer(args map[string]any) *ToolResult { diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go new file mode 100644 index 000000000..f55d152cf --- /dev/null +++ b/pkg/tools/hardware_facade.go @@ -0,0 +1,16 @@ +package tools + +import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware" + +type ( + I2CTool = hardwaretools.I2CTool + SPITool = hardwaretools.SPITool +) + +func NewI2CTool() *I2CTool { + return hardwaretools.NewI2CTool() +} + +func NewSPITool() *SPITool { + return hardwaretools.NewSPITool() +} diff --git a/pkg/tools/identifier_compat.go b/pkg/tools/identifier_compat.go new file mode 100644 index 000000000..c5a6d9cf3 --- /dev/null +++ b/pkg/tools/identifier_compat.go @@ -0,0 +1,48 @@ +package tools + +import "strings" + +func sanitizeIdentifierComponent(s string) string { + const maxLen = 64 + + s = strings.ToLower(s) + var b strings.Builder + b.Grow(len(s)) + + prevUnderscore := false + for _, r := range s { + isAllowed := (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '_' || r == '-' + + if !isAllowed { + if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + continue + } + + if r == '_' { + if prevUnderscore { + continue + } + prevUnderscore = true + } else { + prevUnderscore = false + } + + b.WriteRune(r) + } + + result := strings.Trim(b.String(), "_") + if result == "" { + result = "unnamed" + } + + if len(result) > maxLen { + result = result[:maxLen] + } + + return result +} diff --git a/pkg/tools/integration/helpers.go b/pkg/tools/integration/helpers.go new file mode 100644 index 000000000..b34fbc6cd --- /dev/null +++ b/pkg/tools/integration/helpers.go @@ -0,0 +1,134 @@ +package integrationtools + +import ( + "fmt" + "math" + "mime" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" +) + +var ( + inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`) + inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`) +) + +const ( + largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]" + inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]" +) + +func sanitizeToolLLMContent(text string) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return text + } + if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) { + cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "") + cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + if cleaned == "" { + return inlineMediaOmittedMessage + } + return cleaned + "\n" + inlineMediaOmittedMessage + } + if looksLikeLargeBase64Payload(trimmed) { + return largeBase64OmittedMessage + } + return text +} + +func looksLikeLargeBase64Payload(text string) bool { + trimmed := strings.TrimSpace(text) + if len(trimmed) < 1024 { + return false + } + + nonSpace := 0 + base64Like := 0 + spaceCount := 0 + + for _, r := range trimmed { + if unicode.IsSpace(r) { + spaceCount++ + continue + } + nonSpace++ + if (r >= 'A' && r <= 'Z') || + (r >= 'a' && r <= 'z') || + (r >= '0' && r <= '9') || + r == '+' || r == '/' || r == '=' { + base64Like++ + } + } + + if nonSpace == 0 { + return false + } + + ratio := float64(base64Like) / float64(nonSpace) + return ratio >= 0.97 && spaceCount <= len(trimmed)/128 +} + +func extensionForMIMEType(mimeType string) string { + if mimeType == "" { + return ".bin" + } + if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { + return exts[0] + } + + switch strings.ToLower(mimeType) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "audio/wav", "audio/x-wav": + return ".wav" + case "audio/mpeg": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "video/mp4": + return ".mp4" + default: + return filepath.Ext(mimeType) + } +} + +func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { + raw, exists := args[key] + if !exists { + return defaultVal, nil + } + + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) + } + if v > math.MaxInt64 || v < math.MinInt64 { + return 0, fmt.Errorf("%s value %v overflows int64", key, v) + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) + } +} diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/integration/mcp_tool.go similarity index 97% rename from pkg/tools/mcp_tool.go rename to pkg/tools/integration/mcp_tool.go index 1caf390cf..78c348316 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/integration/mcp_tool.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" ) // MCPManager defines the interface for MCP manager operations @@ -161,6 +162,14 @@ func (t *MCPTool) Description() string { return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc) } +func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata { + return toolshared.PromptMetadata{ + Layer: toolshared.ToolPromptLayerCapability, + Slot: toolshared.ToolPromptSlotMCP, + Source: "mcp:" + sanitizeIdentifierComponent(t.serverName), + } +} + // Parameters returns the tool parameters schema func (t *MCPTool) Parameters() map[string]any { // The InputSchema is already a JSON Schema object diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go similarity index 97% rename from pkg/tools/mcp_tool_test.go rename to pkg/tools/integration/mcp_tool_test.go index f2b02d6f6..7b0b2cd5a 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/integration/mcp_tool_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" @@ -11,6 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/media" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" ) // MockMCPManager is a mock implementation of MCPManager interface for testing @@ -104,6 +105,22 @@ func TestMCPTool_Name(t *testing.T) { } } +func TestMCPTool_PromptMetadata(t *testing.T) { + manager := &MockMCPManager{} + tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"}) + + metadata := tool.PromptMetadata() + if metadata.Layer != toolshared.ToolPromptLayerCapability { + t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability) + } + if metadata.Slot != toolshared.ToolPromptSlotMCP { + t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP) + } + if metadata.Source != "mcp:github_server" { + t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source) + } +} + // TestMCPTool_Description verifies tool description generation func TestMCPTool_Description(t *testing.T) { tests := []struct { diff --git a/pkg/tools/message.go b/pkg/tools/integration/message.go similarity index 76% rename from pkg/tools/message.go rename to pkg/tools/integration/message.go index 39440e5a3..98d87bcb3 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/integration/message.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" @@ -17,11 +17,15 @@ type sentTarget struct { type MessageTool struct { sendCallback SendCallbackWithContext mu sync.Mutex - sentTargets []sentTarget // Tracks all targets sent to in the current round + // sentTargets tracks targets sent to in the current round, keyed by session key + // to support parallel turns for different sessions. + sentTargets map[string][]sentTarget } func NewMessageTool() *MessageTool { - return &MessageTool{} + return &MessageTool{ + sentTargets: make(map[string][]sentTarget), + } } func (t *MessageTool) Name() string { @@ -57,28 +61,31 @@ func (t *MessageTool) Parameters() map[string]any { } } -// ResetSentInRound resets the per-round send tracker. +// ResetSentInRound resets the per-round send tracker for the given session key. // Called by the agent loop at the start of each inbound message processing round. -func (t *MessageTool) ResetSentInRound() { +func (t *MessageTool) ResetSentInRound(sessionKey string) { t.mu.Lock() - t.sentTargets = t.sentTargets[:0] - t.mu.Unlock() + defer t.mu.Unlock() + + // Delete the key entirely to prevent unbounded map growth over time + // with many unique sessions. Truncating the slice keeps the key alive. + delete(t.sentTargets, sessionKey) } // HasSentInRound returns true if the message tool sent a message during the current round. -func (t *MessageTool) HasSentInRound() bool { +func (t *MessageTool) HasSentInRound(sessionKey string) bool { t.mu.Lock() defer t.mu.Unlock() - return len(t.sentTargets) > 0 + return len(t.sentTargets[sessionKey]) > 0 } // HasSentTo returns true if the message tool sent to the specific channel+chatID // during the current round. Used by PublishResponseIfNeeded to avoid suppressing // the final response when the message tool only sent to a different conversation. -func (t *MessageTool) HasSentTo(channel, chatID string) bool { +func (t *MessageTool) HasSentTo(sessionKey, channel, chatID string) bool { t.mu.Lock() defer t.mu.Unlock() - for _, st := range t.sentTargets { + for _, st := range t.sentTargets[sessionKey] { if st.Channel == channel && st.ChatID == chatID { return true } @@ -123,8 +130,9 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes } } + sessionKey := ToolSessionKey(ctx) t.mu.Lock() - t.sentTargets = append(t.sentTargets, sentTarget{Channel: channel, ChatID: chatID}) + t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID}) t.mu.Unlock() // Silent: user already received the message directly diff --git a/pkg/tools/message_test.go b/pkg/tools/integration/message_test.go similarity index 99% rename from pkg/tools/message_test.go rename to pkg/tools/integration/message_test.go index 649593252..c7b7d2b6e 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/integration/message_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/reaction.go b/pkg/tools/integration/reaction.go similarity index 98% rename from pkg/tools/reaction.go rename to pkg/tools/integration/reaction.go index 3455b07a9..5a8dc87be 100644 --- a/pkg/tools/reaction.go +++ b/pkg/tools/integration/reaction.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/reaction_test.go b/pkg/tools/integration/reaction_test.go similarity index 99% rename from pkg/tools/reaction_test.go rename to pkg/tools/integration/reaction_test.go index 6fc90445a..f579fd914 100644 --- a/pkg/tools/reaction_test.go +++ b/pkg/tools/integration/reaction_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/integration/shared.go b/pkg/tools/integration/shared.go new file mode 100644 index 000000000..cc6aa3f28 --- /dev/null +++ b/pkg/tools/integration/shared.go @@ -0,0 +1,77 @@ +package integrationtools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Tool = toolshared.Tool + ToolResult = toolshared.ToolResult + AsyncCallback = toolshared.AsyncCallback +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/tools/skills_install.go b/pkg/tools/integration/skills_install.go similarity index 99% rename from pkg/tools/skills_install.go rename to pkg/tools/integration/skills_install.go index 79d0672b9..1824f2c0a 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/integration/skills_install.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/integration/skills_install_test.go similarity index 99% rename from pkg/tools/skills_install_test.go rename to pkg/tools/integration/skills_install_test.go index 125348883..01d2fd2bc 100644 --- a/pkg/tools/skills_install_test.go +++ b/pkg/tools/integration/skills_install_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_search.go b/pkg/tools/integration/skills_search.go similarity index 99% rename from pkg/tools/skills_search.go rename to pkg/tools/integration/skills_search.go index 2b6cffd38..f080aba95 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/integration/skills_search.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/integration/skills_search_test.go similarity index 99% rename from pkg/tools/skills_search_test.go rename to pkg/tools/integration/skills_search_test.go index 0e5387cf5..fcce48b49 100644 --- a/pkg/tools/skills_search_test.go +++ b/pkg/tools/integration/skills_search_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/tts_send.go b/pkg/tools/integration/tts_send.go similarity index 98% rename from pkg/tools/tts_send.go rename to pkg/tools/integration/tts_send.go index 3d569e3f7..6c9135624 100644 --- a/pkg/tools/tts_send.go +++ b/pkg/tools/integration/tts_send.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "context" diff --git a/pkg/tools/web.go b/pkg/tools/integration/web.go similarity index 87% rename from pkg/tools/web.go rename to pkg/tools/integration/web.go index 2bb8d9b35..75821e40d 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/integration/web.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" @@ -58,8 +58,6 @@ var ( reSogouRealURL = regexp.MustCompile(`url=([^&]+)`) ) -var preferredWebSearchLanguage atomic.Value - type APIKeyPool struct { keys []string current uint32 @@ -250,27 +248,6 @@ func mapBaiduRecencyFilter(rangeCode string) string { } } -func normalizePreferredWebSearchLanguage(lang string) string { - lang = strings.ToLower(strings.TrimSpace(lang)) - switch { - case strings.HasPrefix(lang, "zh"), lang == "chinese": - return "zh" - case strings.HasPrefix(lang, "en"), lang == "english": - return "en" - default: - return "" - } -} - -func SetPreferredWebSearchLanguage(lang string) { - preferredWebSearchLanguage.Store(normalizePreferredWebSearchLanguage(lang)) -} - -func GetPreferredWebSearchLanguage() string { - lang, _ := preferredWebSearchLanguage.Load().(string) - return lang -} - type BraveSearchProvider struct { keyPool *APIKeyPool proxy string @@ -812,6 +789,8 @@ func (p *PerplexitySearchProvider) Search( type SearXNGSearchProvider struct { baseURL string + proxy string + client *http.Client } func (p *SearXNGSearchProvider) Search( @@ -836,7 +815,10 @@ func (p *SearXNGSearchProvider) Search( return "", fmt.Errorf("failed to create request: %w", err) } - client := &http.Client{Timeout: 10 * time.Second} + client := p.client + if client == nil { + client = &http.Client{Timeout: searchTimeout} + } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) @@ -1108,12 +1090,147 @@ type WebSearchToolOptions struct { Proxy string } +func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { + return WebSearchToolOptions{ + Provider: cfg.Tools.Web.Provider, + BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults, + SogouEnabled: cfg.Tools.Web.Sogou.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, + SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, + SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), + GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, + GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, + BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), + BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, + BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, + BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, + Proxy: cfg.Tools.Web.Proxy, + } +} + +func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool { + return opts.providerReady(name) +} + +func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) { + return opts.resolveProviderName(query) +} + +var ( + knownWebSearchProviders = []string{ + "sogou", + "duckduckgo", + "brave", + "tavily", + "perplexity", + "searxng", + "glm_search", + "baidu_search", + } + autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"} + autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"} +) + +func isKnownWebSearchProvider(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + for _, known := range knownWebSearchProviders { + if name == known { + return true + } + } + return false +} + +func (opts WebSearchToolOptions) providerReady(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "sogou": + return opts.SogouEnabled + case "duckduckgo": + return opts.DuckDuckGoEnabled + case "brave": + return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 + case "tavily": + return opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 + case "perplexity": + return opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 + case "searxng": + return opts.SearXNGEnabled && strings.TrimSpace(opts.SearXNGBaseURL) != "" + case "glm_search": + return opts.GLMSearchEnabled && strings.TrimSpace(opts.GLMSearchAPIKey) != "" + case "baidu_search": + return opts.BaiduSearchEnabled && strings.TrimSpace(opts.BaiduSearchAPIKey) != "" + default: + return false + } +} + +func (opts WebSearchToolOptions) normalizedProviderName() string { + providerName := strings.ToLower(strings.TrimSpace(opts.Provider)) + if providerName != "" && providerName != "auto" && !isKnownWebSearchProvider(providerName) { + // Tolerate stale or manually edited config values at runtime by + // treating them as "auto" and falling back to the next ready provider. + return "auto" + } + return providerName +} + +func (opts WebSearchToolOptions) resolveProviderName(query string) (string, error) { + providerName := opts.normalizedProviderName() + if providerName != "" && providerName != "auto" && opts.providerReady(providerName) { + return providerName, nil + } + + for _, name := range autoPrimaryWebSearchProviders { + if opts.providerReady(name) { + return name, nil + } + } + + sogouReady := opts.providerReady("sogou") + duckReady := opts.providerReady("duckduckgo") + if sogouReady && duckReady { + if prefersDuckDuckGoQuery(query) { + return "duckduckgo", nil + } + return "sogou", nil + } + if sogouReady { + return "sogou", nil + } + if duckReady { + return "duckduckgo", nil + } + + for _, name := range autoFallbackWebSearchProviders { + if opts.providerReady(name) { + return name, nil + } + } + + return "", nil +} + func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, int, error) { switch strings.ToLower(strings.TrimSpace(name)) { case "", "auto": return nil, 0, nil case "sogou": - if !opts.SogouEnabled { + if !opts.providerReady("sogou") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1129,7 +1246,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "perplexity": - if !opts.PerplexityEnabled { + if !opts.providerReady("perplexity") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) @@ -1146,7 +1263,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "brave": - if !opts.BraveEnabled { + if !opts.providerReady("brave") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1163,18 +1280,24 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "searxng": - if !opts.SearXNGEnabled { + if !opts.providerReady("searxng") { return nil, 0, nil } + client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, 0, fmt.Errorf("failed to create HTTP client for SearXNG: %w", err) + } maxResults := 10 if opts.SearXNGMaxResults > 0 { maxResults = min(opts.SearXNGMaxResults, 10) } return &SearXNGSearchProvider{ baseURL: opts.SearXNGBaseURL, + proxy: opts.Proxy, + client: client, }, maxResults, nil case "tavily": - if !opts.TavilyEnabled { + if !opts.providerReady("tavily") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1192,7 +1315,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "duckduckgo": - if !opts.DuckDuckGoEnabled { + if !opts.providerReady("duckduckgo") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1208,7 +1331,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "baidu_search": - if !opts.BaiduSearchEnabled { + if !opts.providerReady("baidu_search") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) @@ -1226,7 +1349,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "glm_search": - if !opts.GLMSearchEnabled { + if !opts.providerReady("glm_search") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1274,7 +1397,7 @@ func containsLatinLetter(text string) bool { func prefersDuckDuckGoQuery(text string) bool { trimmed := strings.TrimSpace(text) if trimmed == "" { - return GetPreferredWebSearchLanguage() == "en" + return false } if containsHan(trimmed) { return false @@ -1282,66 +1405,39 @@ func prefersDuckDuckGoQuery(text string) bool { if containsLatinLetter(trimmed) { return true } - return GetPreferredWebSearchLanguage() == "en" + return false } func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) { - providerName := strings.ToLower(strings.TrimSpace(opts.Provider)) - if providerName != "" && providerName != "auto" { - provider, maxResults, err := opts.providerByName(providerName) + providersByName := make(map[string]SearchProvider, len(knownWebSearchProviders)) + maxResultsByName := make(map[string]int, len(knownWebSearchProviders)) + + for _, name := range knownWebSearchProviders { + if !opts.providerReady(name) { + continue + } + provider, maxResults, err := opts.providerByName(name) if err != nil { return nil, err } if provider == nil { - return func(string) (SearchProvider, int) { return nil, 0 }, nil + continue } - return func(string) (SearchProvider, int) { return provider, maxResults }, nil + providersByName[name] = provider + maxResultsByName[name] = maxResults } - for _, name := range []string{"perplexity", "brave", "searxng", "tavily"} { - provider, maxResults, err := opts.providerByName(name) + return func(query string) (SearchProvider, int) { + name, err := opts.resolveProviderName(query) if err != nil { - return nil, err + return nil, 0 } - if provider != nil { - return func(string) (SearchProvider, int) { return provider, maxResults }, nil + provider, ok := providersByName[name] + if !ok { + return nil, 0 } - } - - sogouProvider, sogouMaxResults, err := opts.providerByName("sogou") - if err != nil { - return nil, err - } - duckProvider, duckMaxResults, err := opts.providerByName("duckduckgo") - if err != nil { - return nil, err - } - if sogouProvider != nil && duckProvider != nil { - return func(query string) (SearchProvider, int) { - if prefersDuckDuckGoQuery(query) { - return duckProvider, duckMaxResults - } - return sogouProvider, sogouMaxResults - }, nil - } - if sogouProvider != nil { - return func(string) (SearchProvider, int) { return sogouProvider, sogouMaxResults }, nil - } - if duckProvider != nil { - return func(string) (SearchProvider, int) { return duckProvider, duckMaxResults }, nil - } - - for _, name := range []string{"baidu_search", "glm_search"} { - provider, maxResults, err := opts.providerByName(name) - if err != nil { - return nil, err - } - if provider != nil { - return func(string) (SearchProvider, int) { return provider, maxResults }, nil - } - } - - return func(string) (SearchProvider, int) { return nil, 0 }, nil + return provider, maxResultsByName[name] + }, nil } func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { @@ -1458,6 +1554,8 @@ type privateHostWhitelist struct { cidrs []*net.IPNet } +type webFetchAllowedFirstHopHostKey struct{} + func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { // createHTTPClient cannot fail with an empty proxy string. return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil) @@ -1509,6 +1607,7 @@ func NewWebFetchToolWithConfig( if isObviousPrivateHost(req.URL.Hostname(), whitelist) { return fmt.Errorf("redirect target is private or local network host") } + allowConfiguredProxyFirstHop(req, client.Transport) return nil } if fetchLimitBytes <= 0 { @@ -1588,6 +1687,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe if reqErr != nil { return nil, nil, fmt.Errorf("failed to create request: %w", reqErr) } + allowConfiguredProxyFirstHop(req, t.client.Transport) req.Header.Set("User-Agent", ua) resp, doErr := t.client.Do(req) if doErr != nil { @@ -1790,6 +1890,9 @@ func newSafeDialContext( if host == "" { return nil, fmt.Errorf("empty target host") } + if isAllowedFirstHopHost(ctx, host) { + return dialer.DialContext(ctx, network, address) + } if ip := net.ParseIP(host); ip != nil { if shouldBlockPrivateIP(ip, whitelist) { @@ -1838,6 +1941,46 @@ func newSafeDialContext( } } +func allowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) { + if req == nil { + return + } + + transport, ok := rt.(*http.Transport) + if !ok || transport.Proxy == nil { + return + } + + proxyURL, err := transport.Proxy(req) + if err != nil || proxyURL == nil { + return + } + + host := normalizeAllowedFirstHopHost(proxyURL.Hostname()) + if host == "" { + return + } + + *req = *req.WithContext(context.WithValue( + req.Context(), + webFetchAllowedFirstHopHostKey{}, + host, + )) +} + +func isAllowedFirstHopHost(ctx context.Context, host string) bool { + allowed, _ := ctx.Value(webFetchAllowedFirstHopHostKey{}).(string) + if allowed == "" { + return false + } + return allowed == normalizeAllowedFirstHopHost(host) +} + +func normalizeAllowedFirstHopHost(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + return strings.TrimSuffix(host, ".") +} + func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) { if len(entries) == 0 { return nil, nil diff --git a/pkg/tools/web_test.go b/pkg/tools/integration/web_test.go similarity index 91% rename from pkg/tools/web_test.go rename to pkg/tools/integration/web_test.go index 01f3bcb41..ba6b3da45 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -1,4 +1,4 @@ -package tools +package integrationtools import ( "bytes" @@ -385,24 +385,14 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { } } -// TestWebTool_WebSearch_NoApiKey verifies missing credentials are surfaced at execution time. +// TestWebTool_WebSearch_NoApiKey verifies providers without required credentials are not registered. func TestWebTool_WebSearch_NoApiKey(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil}) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if tool == nil { - t.Fatalf("Expected tool when Brave is enabled, even without API keys") - } - - result := tool.Execute(context.Background(), map[string]any{ - "query": "test query", - }) - if !result.IsError { - t.Fatalf("Expected missing Brave API key to return error") - } - if !strings.Contains(result.ForLLM, "no API key provided") { - t.Fatalf("Unexpected error message: %s", result.ForLLM) + if tool != nil { + t.Fatalf("Expected nil tool when only enabled provider is missing credentials") } // Also nil when nothing is enabled @@ -767,6 +757,33 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) { } } +func TestWebTool_WebFetch_AllowsLoopbackProxy(t *testing.T) { + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() != "http://example.com/proxied" { + t.Fatalf("proxy received URL %q, want %q", r.URL.String(), "http://example.com/proxied") + } + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("proxied content")) + })) + defer proxy.Close() + + tool, err := NewWebFetchToolWithProxy(50000, proxy.URL, format, testFetchLimit, nil) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://example.com/proxied", + }) + if result.IsError { + t.Fatalf("expected success through loopback proxy, got %q", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "proxied content") { + t.Fatalf("expected proxied content, got %q", result.ForLLM) + } +} + // TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) @@ -1092,6 +1109,40 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") } }) + + t.Run("searxng", func(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + SearXNGEnabled: true, + SearXNGBaseURL: "https://searx.example.com", + SearXNGMaxResults: 3, + Proxy: "http://127.0.0.1:7890", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + p, ok := tool.provider.(*SearXNGSearchProvider) + if !ok { + t.Fatalf("provider type = %T, want *SearXNGSearchProvider", tool.provider) + } + if p.proxy != "http://127.0.0.1:7890" { + t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") + } + tr, ok := p.client.Transport.(*http.Transport) + if !ok { + t.Fatalf("client.Transport type = %T, want *http.Transport", p.client.Transport) + } + req, err := http.NewRequest(http.MethodGet, "https://searx.example.com/search", nil) + if err != nil { + t.Fatalf("http.NewRequest() error: %v", err) + } + proxyURL, err := tr.Proxy(req) + if err != nil { + t.Fatalf("transport.Proxy(req) error: %v", err) + } + if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" { + t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890") + } + }) } // TestWebTool_TavilySearch_Success verifies successful Tavily search @@ -1727,11 +1778,6 @@ func TestApplySogouRangeHint(t *testing.T) { } func TestPrefersDuckDuckGoQuery(t *testing.T) { - SetPreferredWebSearchLanguage("") - t.Cleanup(func() { - SetPreferredWebSearchLanguage("") - }) - tests := []struct { name string query string @@ -1754,19 +1800,9 @@ func TestPrefersDuckDuckGoQuery(t *testing.T) { } } -func TestPrefersDuckDuckGoQuery_FallsBackToPreferredLanguage(t *testing.T) { - SetPreferredWebSearchLanguage("en") - t.Cleanup(func() { - SetPreferredWebSearchLanguage("") - }) - - if !prefersDuckDuckGoQuery("2026 04 15") { - t.Fatal("numeric query should prefer DuckDuckGo when preferred language is English") - } - - SetPreferredWebSearchLanguage("zh") +func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(t *testing.T) { if prefersDuckDuckGoQuery("2026 04 15") { - t.Fatal("numeric query should prefer Sogou when preferred language is Chinese") + t.Fatal("numeric query should default to Sogou when no script-specific hint is present") } } @@ -1817,6 +1853,94 @@ func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T) } } +func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "brave", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestWebTool_ExplicitProviderFallsBackWhenMissingBaseURL(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "searxng", + SearXNGEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestWebTool_AutoProviderSkipsEnabledButUnreadyProviders(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "auto", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider when Brave has no API key, got %T", tool.provider) + } +} + +func TestResolveWebSearchProviderName_FallsBackFromExplicitUnavailableProvider(t *testing.T) { + got, err := ResolveWebSearchProviderName(WebSearchToolOptions{ + Provider: "brave", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }, "") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if got != "sogou" { + t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got) + } +} + +func TestWebTool_UnknownExplicitProviderFallsBackToAuto(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "totally_unknown", + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestResolveWebSearchProviderName_FallsBackFromUnknownProvider(t *testing.T) { + got, err := ResolveWebSearchProviderName(WebSearchToolOptions{ + Provider: "totally_unknown", + SogouEnabled: true, + SogouMaxResults: 5, + }, "") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if got != "sogou" { + t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got) + } +} + type stubSearchProvider struct { result string calls []string diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go new file mode 100644 index 000000000..193ecd6f5 --- /dev/null +++ b/pkg/tools/integration_facade.go @@ -0,0 +1,106 @@ +package tools + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/skills" + integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration" +) + +type ( + SendCallbackWithContext = integrationtools.SendCallbackWithContext + ReactionCallback = integrationtools.ReactionCallback + MCPManager = integrationtools.MCPManager + MCPTool = integrationtools.MCPTool + FindSkillsTool = integrationtools.FindSkillsTool + InstallSkillTool = integrationtools.InstallSkillTool + MessageTool = integrationtools.MessageTool + ReactionTool = integrationtools.ReactionTool + SendTTSTool = integrationtools.SendTTSTool + APIKeyPool = integrationtools.APIKeyPool + APIKeyIterator = integrationtools.APIKeyIterator + SearchProvider = integrationtools.SearchProvider + SearchResultItem = integrationtools.SearchResultItem + BraveSearchProvider = integrationtools.BraveSearchProvider + TavilySearchProvider = integrationtools.TavilySearchProvider + SogouSearchProvider = integrationtools.SogouSearchProvider + DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider + PerplexitySearchProvider = integrationtools.PerplexitySearchProvider + SearXNGSearchProvider = integrationtools.SearXNGSearchProvider + GLMSearchProvider = integrationtools.GLMSearchProvider + BaiduSearchProvider = integrationtools.BaiduSearchProvider + WebSearchTool = integrationtools.WebSearchTool + WebSearchToolOptions = integrationtools.WebSearchToolOptions + WebFetchTool = integrationtools.WebFetchTool +) + +func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool { + return integrationtools.NewMCPTool(manager, serverName, tool) +} + +func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { + return integrationtools.NewFindSkillsTool(registryMgr, cache) +} + +func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { + return integrationtools.NewInstallSkillTool(registryMgr, workspace) +} + +func NewMessageTool() *MessageTool { + return integrationtools.NewMessageTool() +} + +func NewReactionTool() *ReactionTool { + return integrationtools.NewReactionTool() +} + +func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool { + return integrationtools.NewSendTTSTool(provider, store) +} + +func NewAPIKeyPool(keys []string) *APIKeyPool { + return integrationtools.NewAPIKeyPool(keys) +} + +func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { + return integrationtools.WebSearchToolOptionsFromConfig(cfg) +} + +func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool { + return integrationtools.WebSearchProviderReady(opts, name) +} + +func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) { + return integrationtools.ResolveWebSearchProviderName(opts, query) +} + +func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { + return integrationtools.NewWebSearchTool(opts) +} + +func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) { + return integrationtools.NewWebFetchTool(maxChars, format, fetchLimitBytes) +} + +func NewWebFetchToolWithProxy( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithProxy(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} + +func NewWebFetchToolWithConfig( + maxChars int, + proxy string, + format string, + fetchLimitBytes int64, + privateHostWhitelist []string, +) (*WebFetchTool, error) { + return integrationtools.NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist) +} diff --git a/pkg/tools/load_image_compat_test.go b/pkg/tools/load_image_compat_test.go new file mode 100644 index 000000000..a29ee2042 --- /dev/null +++ b/pkg/tools/load_image_compat_test.go @@ -0,0 +1,29 @@ +package tools + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { + manager := NewSubagentManager(nil, "gpt-test", "/tmp") + + called := false + manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + called = true + return msgs + }) + + manager.mu.RLock() + got := manager.mediaResolver + manager.mu.RUnlock() + + if got == nil { + t.Fatal("expected mediaResolver to be set") + } + + if called { + t.Fatal("resolver should not be called during SetMediaResolver") + } +} diff --git a/pkg/tools/path_compat.go b/pkg/tools/path_compat.go new file mode 100644 index 000000000..9e677cb2b --- /dev/null +++ b/pkg/tools/path_compat.go @@ -0,0 +1,19 @@ +package tools + +import ( + "regexp" + + fstools "github.com/sipeed/picoclaw/pkg/tools/fs" +) + +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { + return fstools.ValidatePathWithAllowPaths(path, workspace, restrict, patterns) +} + +func isAllowedPath(path string, patterns []*regexp.Regexp) bool { + return fstools.IsAllowedPath(path, patterns) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index e51dff71a..0ff9293a3 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -352,6 +352,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { name, _ := fn["name"].(string) desc, _ := fn["description"].(string) params, _ := fn["parameters"].(map[string]any) + metadata := promptMetadataForTool(entry.Tool) definitions = append(definitions, providers.ToolDefinition{ Type: "function", @@ -360,11 +361,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { Description: desc, Parameters: params, }, + PromptLayer: metadata.Layer, + PromptSlot: metadata.Slot, + PromptSource: metadata.Source, }) } return definitions } +func promptMetadataForTool(tool Tool) PromptMetadata { + metadata := PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceRegistry, + } + if provider, ok := tool.(PromptMetadataProvider); ok { + provided := provider.PromptMetadata() + if provided.Layer != "" { + metadata.Layer = provided.Layer + } + if provided.Slot != "" { + metadata.Slot = provided.Slot + } + if provided.Source != "" { + metadata.Source = provided.Source + } + } + return metadata +} + // List returns a list of all registered tool names. func (r *ToolRegistry) List() []string { r.mu.RLock() diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 16bd30928..eac96382f 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T return m.result } +type mockPromptMetadataTool struct { + mockRegistryTool + metadata PromptMetadata +} + +func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata { + return m.metadata +} + type mockAsyncRegistryTool struct { mockRegistryTool lastCB AsyncCallback @@ -375,6 +384,47 @@ func TestToolToSchema(t *testing.T) { } } +func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("native", "native tool")) + r.Register(&mockPromptMetadataTool{ + mockRegistryTool: mockRegistryTool{ + name: "mcp_demo", + desc: "mcp tool", + params: map[string]any{"type": "object"}, + }, + metadata: PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotMCP, + Source: "mcp:demo", + }, + }) + + defs := r.ToProviderDefs() + if len(defs) != 2 { + t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs)) + } + + byName := make(map[string]providers.ToolDefinition, len(defs)) + for _, def := range defs { + byName[def.Function.Name] = def + } + + native := byName["native"] + if native.PromptLayer != ToolPromptLayerCapability || + native.PromptSlot != ToolPromptSlotTooling || + native.PromptSource != ToolPromptSourceRegistry { + t.Fatalf("native prompt metadata = %#v, want default tooling source", native) + } + + mcp := byName["mcp_demo"] + if mcp.PromptLayer != ToolPromptLayerCapability || + mcp.PromptSlot != ToolPromptSlotMCP || + mcp.PromptSource != "mcp:demo" { + t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp) + } +} + func TestToolRegistry_Clone(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("read_file", "reads files")) diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go index f41c80d90..c5884c9de 100644 --- a/pkg/tools/search_tool.go +++ b/pkg/tools/search_tool.go @@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string { return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools." } +func (t *RegexSearchTool) PromptMetadata() PromptMetadata { + return PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceDiscovery, + } +} + func (t *RegexSearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", @@ -95,6 +103,14 @@ func (t *BM25SearchTool) Description() string { return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools." } +func (t *BM25SearchTool) PromptMetadata() PromptMetadata { + return PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceDiscovery, + } +} + func (t *BM25SearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", diff --git a/pkg/tools/session.go b/pkg/tools/session.go index 141dd4b5e..8c7584254 100644 --- a/pkg/tools/session.go +++ b/pkg/tools/session.go @@ -242,11 +242,3 @@ func (sm *SessionManager) List() []SessionInfo { func generateSessionID() string { return uuid.New().String()[:8] } - -type SessionInfo struct { - ID string `json:"id"` - Command string `json:"command"` - Status string `json:"status"` - PID int `json:"pid"` - StartedAt int64 `json:"startedAt"` -} diff --git a/pkg/tools/base.go b/pkg/tools/shared/base.go similarity index 93% rename from pkg/tools/base.go rename to pkg/tools/shared/base.go index e1f9aacc0..298e1b478 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/shared/base.go @@ -1,4 +1,4 @@ -package tools +package toolshared import ( "context" @@ -14,6 +14,24 @@ type Tool interface { Execute(ctx context.Context, args map[string]any) *ToolResult } +const ( + ToolPromptLayerCapability = "capability" + ToolPromptSlotTooling = "tooling" + ToolPromptSlotMCP = "mcp" + ToolPromptSourceRegistry = "tool_registry:native" + ToolPromptSourceDiscovery = "tool_registry:discovery" +) + +type PromptMetadata struct { + Layer string + Slot string + Source string +} + +type PromptMetadataProvider interface { + PromptMetadata() PromptMetadata +} + // --- Request-scoped tool context (channel / chatID) --- // // Carried via context.Value so that concurrent tool calls each receive diff --git a/pkg/tools/result.go b/pkg/tools/shared/result.go similarity index 95% rename from pkg/tools/result.go rename to pkg/tools/shared/result.go index c81213125..e4b16f7b3 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/shared/result.go @@ -1,4 +1,4 @@ -package tools +package toolshared import ( "encoding/json" @@ -8,8 +8,8 @@ import ( ) const ( - handledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation." - artifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested." + HandledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation." + ArtifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested." ) // ToolResult represents the structured return value from tool execution. @@ -73,14 +73,14 @@ func (tr *ToolResult) ContentForLLM() string { } if tr.ResponseHandled { if content == "" { - return handledToolLLMNote + return HandledToolLLMNote } - if !strings.Contains(content, handledToolLLMNote) { - content += "\n" + handledToolLLMNote + if !strings.Contains(content, HandledToolLLMNote) { + content += "\n" + HandledToolLLMNote } } if len(tr.ArtifactTags) > 0 { - artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote + artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + ArtifactPathsLLMNote if content == "" { content = artifactNote } else if !strings.Contains(content, artifactNote) { diff --git a/pkg/tools/types.go b/pkg/tools/shared/types.go similarity index 91% rename from pkg/tools/types.go rename to pkg/tools/shared/types.go index 4d1a18d5a..8a74d30f3 100644 --- a/pkg/tools/types.go +++ b/pkg/tools/shared/types.go @@ -1,4 +1,4 @@ -package tools +package toolshared import "context" @@ -77,3 +77,11 @@ type ExecResponse struct { Error string `json:"error,omitempty"` Sessions []SessionInfo `json:"sessions,omitempty"` } + +type SessionInfo struct { + ID string `json:"id"` + Command string `json:"command"` + Status string `json:"status"` + PID int `json:"pid"` + StartedAt int64 `json:"startedAt"` +} diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go new file mode 100644 index 000000000..8409ea060 --- /dev/null +++ b/pkg/tools/shared_facade.go @@ -0,0 +1,118 @@ +package tools + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/session" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" +) + +type ( + Message = toolshared.Message + ToolCall = toolshared.ToolCall + FunctionCall = toolshared.FunctionCall + LLMResponse = toolshared.LLMResponse + UsageInfo = toolshared.UsageInfo + LLMProvider = toolshared.LLMProvider + ToolDefinition = toolshared.ToolDefinition + ToolFunctionDefinition = toolshared.ToolFunctionDefinition + ExecRequest = toolshared.ExecRequest + ExecResponse = toolshared.ExecResponse + SessionInfo = toolshared.SessionInfo + Tool = toolshared.Tool + AsyncCallback = toolshared.AsyncCallback + AsyncExecutor = toolshared.AsyncExecutor + PromptMetadata = toolshared.PromptMetadata + PromptMetadataProvider = toolshared.PromptMetadataProvider + ToolResult = toolshared.ToolResult +) + +const ( + handledToolLLMNote = toolshared.HandledToolLLMNote + artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote + + ToolPromptLayerCapability = toolshared.ToolPromptLayerCapability + ToolPromptSlotTooling = toolshared.ToolPromptSlotTooling + ToolPromptSlotMCP = toolshared.ToolPromptSlotMCP + ToolPromptSourceRegistry = toolshared.ToolPromptSourceRegistry + ToolPromptSourceDiscovery = toolshared.ToolPromptSourceDiscovery +) + +func WithToolContext(ctx context.Context, channel, chatID string) context.Context { + return toolshared.WithToolContext(ctx, channel, chatID) +} + +func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context { + return toolshared.WithToolMessageContext(ctx, messageID, replyToMessageID) +} + +func WithToolInboundContext( + ctx context.Context, + channel, chatID, messageID, replyToMessageID string, +) context.Context { + return toolshared.WithToolInboundContext(ctx, channel, chatID, messageID, replyToMessageID) +} + +func WithToolSessionContext( + ctx context.Context, + agentID, sessionKey string, + scope *session.SessionScope, +) context.Context { + return toolshared.WithToolSessionContext(ctx, agentID, sessionKey, scope) +} + +func ToolChannel(ctx context.Context) string { + return toolshared.ToolChannel(ctx) +} + +func ToolChatID(ctx context.Context) string { + return toolshared.ToolChatID(ctx) +} + +func ToolMessageID(ctx context.Context) string { + return toolshared.ToolMessageID(ctx) +} + +func ToolReplyToMessageID(ctx context.Context) string { + return toolshared.ToolReplyToMessageID(ctx) +} + +func ToolAgentID(ctx context.Context) string { + return toolshared.ToolAgentID(ctx) +} + +func ToolSessionKey(ctx context.Context) string { + return toolshared.ToolSessionKey(ctx) +} + +func ToolSessionScope(ctx context.Context) *session.SessionScope { + return toolshared.ToolSessionScope(ctx) +} + +func ToolToSchema(tool Tool) map[string]any { + return toolshared.ToolToSchema(tool) +} + +func NewToolResult(forLLM string) *ToolResult { + return toolshared.NewToolResult(forLLM) +} + +func SilentResult(forLLM string) *ToolResult { + return toolshared.SilentResult(forLLM) +} + +func AsyncResult(forLLM string) *ToolResult { + return toolshared.AsyncResult(forLLM) +} + +func ErrorResult(message string) *ToolResult { + return toolshared.ErrorResult(message) +} + +func UserResult(content string) *ToolResult { + return toolshared.UserResult(content) +} + +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return toolshared.MediaResult(forLLM, mediaRefs) +} diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index a6c8895b8..de7cb467e 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -1,9 +1,67 @@ package utils -import "fmt" +import ( + "fmt" + "strings" +) -// FormatToolFeedbackMessage renders the tool name and arguments preview in the -// same markdown shape used by live tool feedback and session reconstruction. -func FormatToolFeedbackMessage(toolName, argsPreview string) string { - return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview) +const ToolFeedbackContinuationHint = "Continuing the current task." + +// 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. +func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string { + toolName = strings.TrimSpace(toolName) + explanation = strings.TrimSpace(explanation) + argsPreview = strings.TrimSpace(argsPreview) + + bodyLines := make([]string, 0, 2) + if explanation != "" { + bodyLines = append(bodyLines, explanation) + } + if argsPreview != "" { + bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```") + } + body := strings.Join(bodyLines, "\n") + + if toolName == "" { + return body + } + if body == "" { + return fmt.Sprintf("\U0001f527 `%s`", toolName) + } + + return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body) +} + +// FitToolFeedbackMessage keeps tool feedback within a single outbound message. +// It preserves the first line when possible and truncates the explanation body +// instead of letting the message be split into multiple chunks. +func FitToolFeedbackMessage(content string, maxLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxLen <= 0 { + return "" + } + if len([]rune(content)) <= maxLen { + return content + } + + firstLine, rest, hasRest := strings.Cut(content, "\n") + firstLine = strings.TrimSpace(firstLine) + rest = strings.TrimSpace(rest) + + if !hasRest || rest == "" { + return Truncate(firstLine, maxLen) + } + + if len([]rune(firstLine)) >= maxLen { + return Truncate(firstLine, maxLen) + } + + remaining := maxLen - len([]rune(firstLine)) - 1 + if remaining <= 0 { + return Truncate(firstLine, maxLen) + } + + return firstLine + "\n" + Truncate(rest, remaining) } diff --git a/pkg/utils/tool_feedback_dedupe.go b/pkg/utils/tool_feedback_dedupe.go new file mode 100644 index 000000000..b1adb60eb --- /dev/null +++ b/pkg/utils/tool_feedback_dedupe.go @@ -0,0 +1,39 @@ +package utils + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func normalizeToolFeedbackComparisonText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + text = strings.TrimSpace(text) + if text == "" { + return "" + } + return strings.Join(strings.Fields(text), " ") +} + +func ToolCallExplanationDuplicatesContent(content string, toolCalls []providers.ToolCall) bool { + normalizedContent := normalizeToolFeedbackComparisonText(content) + if normalizedContent == "" || len(toolCalls) == 0 { + return false + } + + for _, tc := range toolCalls { + if tc.ExtraContent == nil { + continue + } + explanation := normalizeToolFeedbackComparisonText(tc.ExtraContent.ToolFeedbackExplanation) + if explanation == "" { + continue + } + if explanation == normalizedContent { + return true + } + } + + return false +} diff --git a/pkg/utils/tool_feedback_dedupe_test.go b/pkg/utils/tool_feedback_dedupe_test.go new file mode 100644 index 000000000..cc587080f --- /dev/null +++ b/pkg/utils/tool_feedback_dedupe_test.go @@ -0,0 +1,55 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestToolCallExplanationDuplicatesContent(t *testing.T) { + t.Run("exact duplicate", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }} + + if !ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) { + t.Fatal("expected duplicated content to be detected") + } + }) + + t.Run("whitespace normalized duplicate", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file\nbefore replying.", + }, + }} + + if !ToolCallExplanationDuplicatesContent(" Read the file before replying. ", toolCalls) { + t.Fatal("expected whitespace-only differences to be ignored") + } + }) + + t.Run("distinct content", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }} + + if ToolCallExplanationDuplicatesContent( + "I will summarize the findings after reading the file.", + toolCalls, + ) { + t.Fatal("expected distinct content to remain visible") + } + }) + + t.Run("missing explanation", func(t *testing.T) { + toolCalls := []providers.ToolCall{{}} + if ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) { + t.Fatal("expected empty tool explanations to skip dedupe") + } + }) +} diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index d7a55ce6b..c30f53827 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -3,9 +3,56 @@ package utils import "testing" func TestFormatToolFeedbackMessage(t *testing.T) { - got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}") - want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```" + got := FormatToolFeedbackMessage( + "read_file", + "I will read README.md first to confirm the current project structure.", + "{\n \"path\": \"README.md\"\n}", + ) + want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```" if got != want { t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) } } + +func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}") + want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "") + want := "Continue drafting the final response." + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "", "") + want := "\U0001f527 `read_file`" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { + got := FitToolFeedbackMessage( + "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", + 40, + ) + want := "\U0001f527 `read_file`\nRead README.md first to..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) { + got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10) + want := "\U0001f527 `read..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} diff --git a/pkg/utils/visible_tool_calls.go b/pkg/utils/visible_tool_calls.go new file mode 100644 index 000000000..8c4d89a51 --- /dev/null +++ b/pkg/utils/visible_tool_calls.go @@ -0,0 +1,106 @@ +package utils + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +type VisibleToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function *VisibleToolCallFunction `json:"function,omitempty"` + ExtraContent *VisibleToolCallExtraContent `json:"extra_content,omitempty"` +} + +type VisibleToolCallFunction struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type VisibleToolCallExtraContent struct { + ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` +} + +func BuildVisibleToolCalls( + toolCalls []providers.ToolCall, + maxArgsLen int, +) []VisibleToolCall { + if len(toolCalls) == 0 { + return nil + } + + visible := make([]VisibleToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + name, _ := VisibleToolCallNameAndArguments(tc) + argsPreview := VisibleToolCallArgumentsPreview(tc, maxArgsLen) + explanation := "" + if tc.ExtraContent != nil { + explanation = strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation) + } + if name == "" && explanation == "" && argsPreview == "" { + continue + } + + visibleCall := VisibleToolCall{ + ID: strings.TrimSpace(tc.ID), + Type: strings.TrimSpace(tc.Type), + } + if visibleCall.Type == "" { + visibleCall.Type = "function" + } + if name != "" || argsPreview != "" { + visibleCall.Function = &VisibleToolCallFunction{ + Name: name, + Arguments: argsPreview, + } + } + if explanation != "" { + visibleCall.ExtraContent = &VisibleToolCallExtraContent{ + ToolFeedbackExplanation: explanation, + } + } + + visible = append(visible, visibleCall) + } + + if len(visible) == 0 { + return nil + } + return visible +} + +func VisibleToolCallNameAndArguments(tc providers.ToolCall) (string, string) { + name := strings.TrimSpace(tc.Name) + argsJSON := "" + if tc.Function != nil { + if name == "" { + name = strings.TrimSpace(tc.Function.Name) + } + argsJSON = strings.TrimSpace(tc.Function.Arguments) + } + if argsJSON == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + return name, strings.TrimSpace(argsJSON) +} + +func VisibleToolCallArgumentsPreview(tc providers.ToolCall, maxLen int) string { + _, argsJSON := VisibleToolCallNameAndArguments(tc) + if argsJSON == "" { + return "" + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil { + argsJSON = pretty.String() + } + if maxLen > 0 { + return Truncate(argsJSON, maxLen) + } + return argsJSON +} diff --git a/pkg/utils/visible_tool_calls_test.go b/pkg/utils/visible_tool_calls_test.go new file mode 100644 index 000000000..fe9467c57 --- /dev/null +++ b/pkg/utils/visible_tool_calls_test.go @@ -0,0 +1,33 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestBuildVisibleToolCalls_DoesNotTruncateExplanation(t *testing.T) { + explanation := "Read README.md first to confirm the current project structure before editing the config example." + toolCalls := []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, + }} + + visible := BuildVisibleToolCalls(toolCalls, 20) + if len(visible) != 1 { + t.Fatalf("len(visible) = %d, want 1", len(visible)) + } + if visible[0].ExtraContent == nil || visible[0].ExtraContent.ToolFeedbackExplanation != explanation { + t.Fatalf("visible explanation = %#v, want %q", visible[0].ExtraContent, explanation) + } + if visible[0].Function == nil || visible[0].Function.Arguments == "" { + t.Fatalf("visible function = %#v, want truncated args preview", visible[0].Function) + } +} diff --git a/scripts/copydir.go b/scripts/copydir.go new file mode 100644 index 000000000..6e2777612 --- /dev/null +++ b/scripts/copydir.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" +) + +func main() { + if len(os.Args) != 3 { + fmt.Fprintf(os.Stderr, "usage: go run scripts/copydir.go \n") + os.Exit(2) + } + + repoRoot, err := findRepoRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "locate repo root: %v\n", err) + os.Exit(1) + } + + src, err := normalizePathArg(os.Args[1], repoRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve src path: %v\n", err) + os.Exit(1) + } + + dst, err := normalizePathArg(os.Args[2], repoRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve dst path: %v\n", err) + os.Exit(1) + } + + if err := ensurePathWithinRepo(repoRoot, src); err != nil { + fmt.Fprintf(os.Stderr, "invalid src path: %v\n", err) + os.Exit(1) + } + if err := ensurePathWithinRepo(repoRoot, dst); err != nil { + fmt.Fprintf(os.Stderr, "invalid dst path: %v\n", err) + os.Exit(1) + } + if samePath(repoRoot, dst) { + fmt.Fprintln(os.Stderr, "invalid dst path: destination cannot be repo root") + os.Exit(1) + } + + if err := os.RemoveAll(dst); err != nil { + fmt.Fprintf(os.Stderr, "remove %s: %v\n", dst, err) + os.Exit(1) + } + + if err := copyTree(src, dst); err != nil { + fmt.Fprintf(os.Stderr, "copy %s -> %s: %v\n", src, dst, err) + os.Exit(1) + } +} + +func findRepoRoot() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("unable to locate copydir.go source path") + } + + scriptDir := filepath.Dir(file) + candidate := filepath.Clean(filepath.Join(scriptDir, "..")) + if err := validateRepoRoot(candidate); err == nil { + return candidate, nil + } + + wd, err := os.Getwd() + if err != nil { + return "", err + } + + cur, err := filepath.Abs(wd) + if err != nil { + return "", err + } + + for { + if err := validateRepoRoot(cur); err == nil { + return filepath.Clean(cur), nil + } + parent := filepath.Dir(cur) + if parent == cur { + return "", fmt.Errorf("could not find repository root from %s", wd) + } + cur = parent + } +} + +func validateRepoRoot(root string) error { + anchors := []string{ + filepath.Join(root, "go.sum"), + filepath.Join(root, "LICENSE"), + filepath.Join(root, ".github"), + } + for _, anchor := range anchors { + if _, err := os.Stat(anchor); err != nil { + return fmt.Errorf("missing repo anchor %s: %w", anchor, err) + } + } + return nil +} + +func normalizePathArg(arg, repoRoot string) (string, error) { + resolved := strings.ReplaceAll(arg, "${codespace}", repoRoot) + abs, err := filepath.Abs(resolved) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func ensurePathWithinRepo(repoRoot, path string) error { + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("path %s is outside repository root %s", path, repoRoot) + } + return nil +} + +func samePath(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) +} + +func copyTree(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("source is not a directory: %s", src) + } + + return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + target := dst + if rel != "." { + target = filepath.Join(dst, rel) + } + + if entry.IsDir() { + return os.MkdirAll(target, entry.Mode()) + } + + return copyFile(path, target, entry.Mode()) + }) +} + +func copyFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + return out.Close() +} diff --git a/scripts/lint-docs.sh b/scripts/lint-docs.sh new file mode 100755 index 000000000..7351298b6 --- /dev/null +++ b/scripts/lint-docs.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +failures=0 + +error() { + local path="$1" + local reason="$2" + local suggestion="${3:-}" + + echo "docs lint: $path" >&2 + echo " reason: $reason" >&2 + if [[ -n "$suggestion" ]]; then + echo " fix: $suggestion" >&2 + fi + failures=1 +} + +lowercase() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +suggest_noncanonical_translation_name() { + local path="$1" + local dir + local base + local stem + local locale + + dir="$(dirname "$path")" + base="$(basename "$path")" + + if [[ "$base" =~ ^(.+)_([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then + stem="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + printf '%s/%s.%s.md' "$dir" "$stem" "$locale" + return + fi + + if [[ "$base" =~ ^(.+)\.([A-Za-z]{2}(-[A-Za-z]{2})?)\.md$ ]]; then + stem="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + printf '%s/%s.%s.md' "$dir" "$stem" "$locale" + return + fi + + printf 'rename it to use a lowercase ..md suffix beside the English source' +} + +suggest_docs_language_bucket_target() { + local path="$1" + local locale + local file + local name + local -a matches + + if [[ "$path" =~ ^docs/([A-Za-z]{2}(-[A-Za-z]{2})?)/.+\.md$ ]]; then + locale="$(lowercase "${BASH_REMATCH[1]}")" + file="$(basename "$path")" + name="${file%.md}" + mapfile -t matches < <(find docs/project docs/guides docs/reference docs/operations docs/security docs/architecture docs/channels docs/design docs/migration -type f -name "${name}.md" 2>/dev/null | sort) + if [[ "${#matches[@]}" -eq 1 ]]; then + printf '%s' "${matches[0]%.md}.${locale}.md" + return + fi + fi + + printf 'move it to a typed docs directory and rename it to ..md beside the English source' +} + +suggest_nested_locale_bucket_target() { + local path="$1" + local prefix + local locale + local rest + + if [[ "$path" =~ ^(docs/(project|guides|reference|operations|security|architecture|design|migration))/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then + prefix="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[3]}")" + rest="${BASH_REMATCH[5]}" + printf '%s/%s.%s.md' "$prefix" "$rest" "$locale" + return + fi + + if [[ "$path" =~ ^(docs/channels/[^/]+)/([A-Za-z]{2}(-[A-Za-z]{2})?)/(.*)\.md$ ]]; then + prefix="${BASH_REMATCH[1]}" + locale="$(lowercase "${BASH_REMATCH[2]}")" + rest="${BASH_REMATCH[4]}" + printf '%s/%s.%s.md' "$prefix" "$rest" "$locale" + return + fi + + printf 'move the file beside its English source and rename it to ..md' +} + +is_noncanonical_translation_name() { + local path="$1" + local base + + base="$(basename "$path")" + + [[ "$base" =~ ^.+_[A-Za-z]{2}(-[A-Za-z]{2})?\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[A-Z]{2}(-[A-Z]{2})?\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[a-z]{2}-[A-Z]{2}\.md$ ]] && return 0 + [[ "$base" =~ ^.+\.[A-Z]{2}-[a-z]{2}\.md$ ]] && return 0 + + return 1 +} + +is_noncanonical_locale_bucket() { + local path="$1" + + [[ "$path" =~ ^docs/(project|guides|reference|operations|security|architecture|design|migration)/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0 + [[ "$path" =~ ^docs/channels/[^/]+/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] && return 0 + return 1 +} + +is_root_docs_language_bucket() { + local path="$1" + [[ "$path" =~ ^docs/[A-Za-z]{2}(-[A-Za-z]{2})?/ ]] +} + +is_translation_file() { + local path="$1" + [[ "$path" =~ ^(.+)\.([a-z]{2})(-[a-z]{2})?\.md$ ]] +} + +translation_base() { + local path="$1" + local locale="$2" + + if [[ "$path" == docs/project/* ]]; then + local rel="${path#docs/project/}" + echo "${rel%.$locale.md}.md" + return + fi + + echo "${path%.$locale.md}.md" +} + +while IFS= read -r path; do + [[ -f "$path" ]] || continue + + case "$path" in + README.*.md) + error \ + "$path" \ + "translated project entry docs must live under docs/project/" \ + "move it to docs/project/$(basename "$path")" + ;; + CONTRIBUTING.*.md) + error \ + "$path" \ + "translated project entry docs must live under docs/project/" \ + "move it to docs/project/$(basename "$path")" + ;; + esac + + if [[ "$path" =~ (^|/)README_[A-Za-z0-9-]+\.md$ ]]; then + error \ + "$path" \ + "legacy README translation names are not allowed" \ + "rename it to use README..md, for example $(suggest_noncanonical_translation_name "$path")" + fi + + if is_noncanonical_translation_name "$path"; then + error \ + "$path" \ + "translation files must use lowercase ..md suffixes and no underscore variants" \ + "rename it to $(suggest_noncanonical_translation_name "$path")" + fi + + if is_root_docs_language_bucket "$path"; then + error \ + "$path" \ + "language bucket directories under docs/ are not allowed" \ + "move it to $(suggest_docs_language_bucket_target "$path")" + fi + + if is_noncanonical_locale_bucket "$path"; then + error \ + "$path" \ + "translations must live beside the English source, not under locale-named subdirectories" \ + "move it to $(suggest_nested_locale_bucket_target "$path")" + fi + + if [[ "$path" =~ ^docs/[^/]+\.md$ && "$path" != "docs/README.md" ]]; then + error \ + "$path" \ + "top-level docs Markdown files must move into a typed docs/ subdirectory" \ + "move it into one of docs/project/, docs/guides/, docs/reference/, docs/operations/, docs/security/, docs/architecture/, docs/channels/, docs/design/, or docs/migration/" + fi + + if is_translation_file "$path"; then + locale="${BASH_REMATCH[2]}${BASH_REMATCH[3]}" + + if [[ "$path" == docs/design/* ]]; then + continue + fi + + base="$(translation_base "$path" "$locale")" + if [[ ! -f "$base" ]]; then + error \ + "$path" \ + "missing English source document '$base'" \ + "add the English source document at '$base' or move this translation beside the correct English source" + fi + fi +done < <(git ls-files --cached --others --exclude-standard -- '*.md') + +if [[ "$failures" -ne 0 ]]; then + echo "docs lint: failed" >&2 + exit 1 +fi + +echo "docs lint: OK" diff --git a/web/Makefile b/web/Makefile index 4dca810e7..254c439e9 100644 --- a/web/Makefile +++ b/web/Makefile @@ -2,15 +2,24 @@ build-android-arm64 build-android-bundle # Go variables -GO?=CGO_ENABLED=0 go +GO?=go WEB_GO?=$(GO) +CGO_ENABLED?=0 GO_BUILD_TAGS?=goolm,stdjson GOFLAGS?=-v -tags $(GO_BUILD_TAGS) +GOCACHE?=$(abspath ../.cache/go-build) +GOMODCACHE?=$(abspath ../.cache/go-mod) +GOTOOLCHAIN?=local +export CGO_ENABLED +export GOCACHE +export GOMODCACHE +export GOTOOLCHAIN # Build variables BUILD_DIR=build -OUTPUT?=$(BUILD_DIR)/picoclaw-launcher -OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64 +EXT= +OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT) +OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT) FRONTEND_DIR=frontend FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp BACKEND_DIR=backend @@ -19,18 +28,47 @@ PICOCLAW_BINARY_NAME=picoclaw PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME)) LAUNCHER_GUI_LDFLAG= +ifeq ($(OS),Windows_NT) + POWERSHELL=powershell -NoProfile -Command + WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL)) +endif + # Version -VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") -BUILD_TIME=$(shell date +%FT%T%z) -GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}') +ifeq ($(OS),Windows_NT) + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL)) + BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'")) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL)) +else + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null)) + BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z)) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null)) +endif +VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev) +GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev) +BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev) +GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown) CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config 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 # OS detection -UNAME_S:=$(shell uname -s) -UNAME_M:=$(shell uname -m) +ifeq ($(OS),Windows_NT) + UNAME_S=Windows + ifeq ($(WINDOWS_GOARCH_RAW),amd64) + UNAME_M=x86_64 + else ifeq ($(WINDOWS_GOARCH_RAW),arm64) + UNAME_M=arm64 + else ifeq ($(WINDOWS_GOARCH_RAW),386) + UNAME_M=x86 + else + UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64) + endif +else + UNAME_S:=$(shell uname -s) + UNAME_M:=$(shell uname -m) +endif # Platform-specific settings ifeq ($(UNAME_S),Linux) @@ -62,7 +100,14 @@ else ifeq ($(UNAME_S),Darwin) endif else ifeq ($(UNAME_S),Windows) PLATFORM=windows - ARCH=$(UNAME_M) + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH=arm64 + else + ARCH=$(UNAME_M) + endif + EXT=.exe PICOCLAW_BINARY_NAME=picoclaw.exe LAUNCHER_GUI_LDFLAG=-H=windowsgui else @@ -91,21 +136,36 @@ dev-backend: # Build frontend and embed into Go binary build: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(OUTPUT)') | Out-Null" +else @mkdir -p "$$(dirname "$(OUTPUT)")" +endif ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/ # Build launcher for Android ARM64 (frontend must already be built) build-android-arm64: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" +else @mkdir -p $(BUILD_DIR) +endif GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/ # Build launcher for all Android architectures build-android-bundle: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" +else @mkdir -p $(BUILD_DIR) +endif GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/ @echo "All Android launcher builds complete" build-frontend: +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "if ((-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules/.bin/tsc')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_INSTALL_STAMP)')) -or ((Get-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Raw).Trim() -ne (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)))) { Write-Host 'Installing frontend dependencies...'; Push-Location '$(FRONTEND_DIR)'; try { pnpm install --frozen-lockfile } finally { Pop-Location }; Set-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Value (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)) -NoNewline }" +else @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \ if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \ @@ -115,12 +175,17 @@ build-frontend: (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \ printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \ fi +endif @echo "Building frontend..." @cd $(FRONTEND_DIR) && pnpm build:backend build-dev-picoclaw: @echo "Building picoclaw for launcher development..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(PICOCLAW_BINARY)') | Out-Null" +else @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")" +endif @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw # Run all tests @@ -135,5 +200,10 @@ lint: # Clean build artifacts clean: +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "$$paths=@('$(FRONTEND_DIR)/dist','$(BACKEND_DIST)','$(BUILD_DIR)'); foreach($$p in $$paths){ if (Test-Path -LiteralPath $$p) { Remove-Item -LiteralPath $$p -Recurse -Force } }" + @node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs +else rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR) node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs +endif diff --git a/web/README.md b/web/README.md index 9fc7007e9..2a57524e0 100644 --- a/web/README.md +++ b/web/README.md @@ -121,23 +121,18 @@ When a gateway process is started by the launcher, the launcher: ### Launcher Authentication -The dashboard is protected by a launcher access token. +The dashboard is protected by password login. -- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used. -- Otherwise a random token is generated for each launcher process. -- The browser auto-open URL includes `?token=...` so local launches can sign in automatically. +- First run uses `/launcher-setup` to create the dashboard password. - Manual login uses `/launcher-login`. -- API clients may also authenticate with `Authorization: Bearer `. - -Where users can retrieve the token depends on launch mode: - -- Console mode: printed to stdout -- GUI mode: available through the tray menu on supported builds -- GUI mode without stdout: - - random per-run tokens are written to the launcher log - - default log path: `~/.picoclaw/logs/launcher.log` - - if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log` - - env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use +- Successful login sets an HttpOnly session cookie. +- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days. +- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. +- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`. +- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config. +- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password. +- URL token login and `Authorization: Bearer` dashboard auth are not supported. ### Network Exposure @@ -155,7 +150,7 @@ With `-public` or `public: true`, it listens on all interfaces: When public access is enabled: -- the launcher can still protect the dashboard with the access token +- the launcher still protects the dashboard with password login - optional `allowed_cidrs` can restrict which client IP ranges may connect - the gateway host is overridden so remote clients can still use the launcher-managed proxy paths @@ -336,19 +331,8 @@ web/ ### You have to sign in again after the launcher restarts Existing dashboard sessions do not survive launcher restarts. -That is expected: each launcher process generates a new signed session value, so old cookies become invalid. - -To make re-login easier, set a stable token: - -```bash -export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token" -``` - -Notes: - -- a stable token does not preserve the old cookie-based session by itself -- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically -- if you reopen the dashboard manually, use the same stable token on `/launcher-login` +That is expected: each launcher process generates a new session value, so old cookies become invalid. +Sign in again with the dashboard password on `/launcher-login`. ### "Start Gateway" stays disabled @@ -377,7 +361,7 @@ If you run only `make dev-backend`, either run `make dev-frontend` alongside it ## Related Docs - Main project overview: [`../README.md`](../README.md) -- Configuration guide: [`../docs/configuration.md`](../docs/configuration.md) -- Providers: [`../docs/providers.md`](../docs/providers.md) -- Troubleshooting: [`../docs/troubleshooting.md`](../docs/troubleshooting.md) +- Configuration guide: [`../docs/guides/configuration.md`](../docs/guides/configuration.md) +- Providers: [`../docs/guides/providers.md`](../docs/guides/providers.md) +- Troubleshooting: [`../docs/operations/troubleshooting.md`](../docs/operations/troubleshooting.md) - Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io) diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index 3cfc3e20d..da07b76c0 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -12,9 +12,8 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -// PasswordStore is the interface for bcrypt-backed dashboard password persistence. -// Implemented by dashboardauth.Store; a nil value falls back to the legacy -// static-token comparison. +// PasswordStore is the interface for dashboard password persistence. +// Implemented by dashboardauth.Store and launcherconfig.PasswordStore. type PasswordStore interface { IsInitialized(ctx context.Context) (bool, error) SetPassword(ctx context.Context, plain string) error @@ -23,18 +22,13 @@ type PasswordStore interface { // LauncherAuthRouteOpts configures dashboard auth handlers. type LauncherAuthRouteOpts struct { - // DashboardToken is the fallback plaintext token used when PasswordStore is - // nil or not yet initialized (env-var / config-file source, and ?token= auto-login). - DashboardToken string - SessionCookie string - SecureCookie func(*http.Request) bool - // PasswordStore enables bcrypt-backed password persistence. When non-nil and - // initialized, web-form login verifies against the stored hash instead of - // the plaintext DashboardToken. + SessionCookie string + SecureCookie func(*http.Request) bool + // PasswordStore enables password login. It must be non-nil for auth to work. PasswordStore PasswordStore // StoreError holds the error returned when opening the password store. When - // non-nil and PasswordStore is nil, the auth endpoints surface a recovery - // message instead of an opaque 501/503. + // non-nil and PasswordStore is nil, auth endpoints fail closed with a + // recovery message. StoreError error } @@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) secure = middleware.DefaultLauncherDashboardSecureCookie } h := &launcherAuthHandlers{ - token: opts.DashboardToken, sessionCookie: opts.SessionCookie, secureCookie: secure, store: opts.PasswordStore, @@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) } type launcherAuthHandlers struct { - token string sessionCookie string secureCookie func(*http.Request) bool store PasswordStore @@ -81,29 +73,18 @@ type launcherAuthHandlers struct { loginLimit *loginRateLimiter } -func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool { - return h.store == nil && h.storeErr == nil && h.token != "" -} - // isStoreInitialized safely queries the store. -// Returns (true, nil) when legacy token auth is active without a password store. -// Returns (false, nil) when no store/token fallback is configured. // Returns (false, err) on store errors — callers must treat this as a 5xx, not as // "uninitialized", to keep auth fail-closed. -// Exception: handleLogin swallows storeErr and falls back to token auth so -// that a corrupt DB does not lock out all access. func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) { if h.store == nil { if h.storeErr != nil { return false, fmt.Errorf( "password store unavailable (%w); "+ - "to recover, stop the application, delete the database file and restart ", + "to recover, stop the application, reset dashboard password storage, and restart", h.storeErr) } - if h.usesLegacyTokenAuth() { - return true, nil - } - return false, nil + return false, fmt.Errorf("password store not configured") } return h.store.IsInitialized(ctx) } @@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques return } in := strings.TrimSpace(body.Password) - var ok bool initialized, initErr := h.isStoreInitialized(r.Context()) if initErr != nil { - if h.storeErr != nil { - // Store failed to open at startup — token login remains available. - initialized = false - } else { - w.WriteHeader(http.StatusInternalServerError) - writeErrorf(w, "%v", initErr) - return - } + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } + if !initialized { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"password has not been set"}`)) + return } - if initialized && h.store != nil { - // Bcrypt path: verify against the stored hash. - var err error - ok, err = h.store.VerifyPassword(r.Context(), in) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - writeErrorf(w, "password verification failed: %v", err) - return - } - } else { - // Fallback: constant-time compare against the plaintext token. - ok = len(in) == len(h.token) && - subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1 + ok, err := h.store.VerifyPassword(r.Context(), in) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeErrorf(w, "password verification failed: %v", err) + return } - if !ok { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"invalid password"}`)) @@ -221,22 +192,19 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque // handleSetup sets or changes the dashboard password. // // Rules: -// - If the store has no password yet, the endpoint is open (no session required). +// - If the store has no password yet, anyone who can reach the setup endpoint +// may initialize the password. // - If a password is already set, the caller must hold a valid session cookie. func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if h.usesLegacyTokenAuth() { - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write( - []byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`), - ) - return - } - if h.store == nil { - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + w.WriteHeader(http.StatusServiceUnavailable) + if h.storeErr != nil { + writeErrorf(w, "password store unavailable: %v", h.storeErr) + } else { + _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + } return } diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go index 58f819ec6..f7f6037a0 100644 --- a/web/backend/api/auth_test.go +++ b/web/backend/api/auth_test.go @@ -2,7 +2,9 @@ package api import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -12,17 +14,43 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -func TestLauncherAuthLoginAndStatus(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = 0x55 +type fakePasswordStore struct { + initialized bool + password string + err error +} + +func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) { + if s.err != nil { + return false, s.err } - const tok = "dashboard-test-token-9" - sess := middleware.SessionCookieValue(key, tok) + return s.initialized, nil +} + +func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error { + if s.err != nil { + return s.err + } + s.password = plain + s.initialized = true + return nil +} + +func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) { + if s.err != nil { + return false, s.err + } + return s.initialized && plain == s.password, nil +} + +func TestLauncherAuthLoginAndStatus(t *testing.T) { + const password = "dashboard-test-password" + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: password} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, }) t.Run("status_unauthenticated", func(t *testing.T) { @@ -45,7 +73,7 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { t.Run("login_ok", func(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "127.0.0.1:12345" mux.ServeHTTP(rec, req) @@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { }) } -func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { - key := make([]byte, 32) - const tok = "legacy-fallback-token" - sess := middleware.SessionCookieValue(key, tok) +func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, }) rec := httptest.NewRecorder() @@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } - if !body.Initialized { - t.Fatalf("initialized = false, want true in legacy token fallback mode") + if body.Initialized { + t.Fatalf("initialized = true, want false before setup") } if body.Authenticated { t.Fatalf("unexpected authenticated=true: %+v", body) } rec = httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest( + http.MethodPost, + "/api/auth/setup", + strings.NewReader(`{"password":"12345678","confirm":"12345678"}`), + ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String()) + t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String()) } } -func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "legacy-token") +func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: "old-password"} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "legacy-token", - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, + }) + + body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String()) + } + + body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess}) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String()) + } + if store.password != "new-password" { + t.Fatalf("password = %q, want new-password", store.password) + } +} + +func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) { + store := &fakePasswordStore{} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + PasswordStore: store, }) rec := httptest.NewRecorder() @@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) { ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) - if rec.Code != http.StatusNotImplemented { - t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) { + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + StoreError: errors.New("open auth store"), + }) + + for _, tc := range []struct { + name string + method string + path string + body string + }{ + {name: "status", method: http.MethodGet, path: "/api/auth/status"}, + {name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`}, + {name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String()) + } + }) } } func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() @@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { } func TestLauncherAuthLoginRateLimit(t *testing.T) { - key := make([]byte, 32) - const tok = "rate-limit-tok-xxxxxxxx" - sess := middleware.SessionCookieValue(key, tok) + store := &fakePasswordStore{initialized: true, password: "correct-password"} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: "session-cookie-value", + PasswordStore: store, }) - // 11 failing logins by wrong token; each consumes allow() slot after valid JSON. + // 11 failing logins by wrong password; each consumes allow() slot after valid JSON. wrongBody := `{"password":"wrong"}` for i := 0; i < loginAttemptsPerIP; i++ { rec := httptest.NewRecorder() @@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) { } func TestLauncherAuthLogoutEmptyBody(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) @@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) { } func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index d5b65eda5..82cd54b72 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -117,8 +117,11 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha bc := cfg.Channels.Get(item.ConfigKey) if bc == nil { - resp.Config = map[string]any{} - return resp + bc = defaultChannelConfig(item.ConfigKey) + if bc == nil { + resp.Config = map[string]any{} + return resp + } } // Detect configured secrets by checking the raw Settings JSON @@ -126,21 +129,47 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha resp.ConfiguredSecrets = secrets // Parse settings into a generic map for JSON response - var settings map[string]any - if err := json.Unmarshal(bc.Settings, &settings); err != nil { - resp.Config = map[string]any{} - return resp + settings := map[string]any{} + if len(bc.Settings) > 0 { + if err := json.Unmarshal(bc.Settings, &settings); err != nil { + resp.Config = map[string]any{} + return resp + } } // Remove secure fields from response for _, key := range secrets { delete(settings, key) } + addChannelCommonConfig(settings, bc) resp.Config = settings return resp } +func defaultChannelConfig(configKey string) *config.Channel { + return config.DefaultConfig().Channels.Get(configKey) +} + +func addChannelCommonConfig(settings map[string]any, bc *config.Channel) { + settings["enabled"] = bc.Enabled + if len(bc.AllowFrom) > 0 { + settings["allow_from"] = []string(bc.AllowFrom) + } + if bc.ReasoningChannelID != "" { + settings["reasoning_channel_id"] = bc.ReasoningChannelID + } + if bc.GroupTrigger.MentionOnly || len(bc.GroupTrigger.Prefixes) > 0 { + settings["group_trigger"] = bc.GroupTrigger + } + if bc.Typing.Enabled { + settings["typing"] = bc.Typing + } + if bc.Placeholder.Enabled || len(bc.Placeholder.Text) > 0 { + settings["placeholder"] = bc.Placeholder + } +} + func detectConfiguredSecrets(settings config.RawNode, channelName string) []string { var m map[string]any if err := json.Unmarshal(settings, &m); err != nil { diff --git a/web/backend/api/channels_test.go b/web/backend/api/channels_test.go index cad96fc64..0208af8e7 100644 --- a/web/backend/api/channels_test.go +++ b/web/backend/api/channels_test.go @@ -27,6 +27,7 @@ func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *te bcfg := decoded.(*config.FeishuSettings) bcfg.AppID = "cli_test_app" bcfg.AppSecret = *config.NewSecureString("feishu-secret-from-security") + bc.AllowFrom = config.FlexibleStringSlice{"ou_test_user"} if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -67,6 +68,13 @@ func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *te if got := resp.Config["app_id"]; got != "cli_test_app" { t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app") } + if got := resp.Config["enabled"]; got != true { + t.Fatalf("config.enabled = %#v, want true", got) + } + allowFrom, ok := resp.Config["allow_from"].([]any) + if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_test_user" { + t.Fatalf("config.allow_from = %#v, want [\"ou_test_user\"]", resp.Config["allow_from"]) + } if _, exists := resp.Config["app_secret"]; exists { t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"]) } @@ -91,3 +99,97 @@ func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) { t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound) } } + +func TestHandleGetChannelConfig_ReturnsCommonFieldsWhenSettingsEmpty(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + bc := cfg.Channels[config.ChannelFeishu] + bc.Enabled = true + bc.AllowFrom = config.FlexibleStringSlice{"ou_common_user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf( + "GET /api/channels/feishu/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) + } + + var resp struct { + Config map[string]any `json:"config"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got := resp.Config["enabled"]; got != true { + t.Fatalf("config.enabled = %#v, want true", got) + } + allowFrom, ok := resp.Config["allow_from"].([]any) + if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_common_user" { + t.Fatalf("config.allow_from = %#v, want [\"ou_common_user\"]", resp.Config["allow_from"]) + } +} + +func TestHandleGetChannelConfig_ReturnsDefaultShapeForMissingChannel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + delete(cfg.Channels, config.ChannelIRC) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/channels/irc/config", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf( + "GET /api/channels/irc/config status = %d, want %d, body=%s", + rec.Code, + http.StatusOK, + rec.Body.String(), + ) + } + + var resp struct { + Config map[string]any `json:"config"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got := resp.Config["server"]; got != "" { + t.Fatalf("config.server = %#v, want empty string", got) + } + if got := resp.Config["nick"]; got != "picoclaw" { + t.Fatalf("config.nick = %#v, want %q", got, "picoclaw") + } + if got := resp.Config["enabled"]; got != false { + t.Fatalf("config.enabled = %#v, want false", got) + } +} diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 80ab80f35..afcd3f74e 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var cfg config.Config - if err = json.Unmarshal(body, &cfg); err != nil { + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } - var raw map[string]any - if err = json.Unmarshal(body, &raw); err != nil { + if err = normalizeChannelArrayFields(raw); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } + normalizedBody, err := json.Marshal(raw) + if err != nil { + http.Error(w, "Failed to normalize config payload", http.StatusBadRequest) + return + } + var cfg config.Config + if err = json.Unmarshal(normalizedBody, &cfg); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } @@ -94,8 +103,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token in case user changed it. - refreshPicoToken(&cfg) h.applyRuntimeLogLevel() logger.Infof("configuration updated successfully") @@ -156,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { // Recursively merge patch into base mergeMap(base, patch) + if err = normalizeChannelArrayFields(base); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } // Convert merged map back to Config struct merged, err := json.Marshal(base) @@ -193,8 +204,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token in case user changed it. - refreshPicoToken(&newCfg) h.applyRuntimeLogLevel() logger.Infof("configuration updated successfully") @@ -386,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) { return m, isMap } +var ( + allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]") + allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+") + conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+") +) + +type stringArrayParserOptions struct { + stripHiddenChars bool +} + +func normalizeChannelArrayFields(raw map[string]any) error { + channelsMap, hasChannels := asMapField(raw, "channel_list") + if !hasChannels { + return nil + } + + defaultCfg := config.DefaultConfig() + for channelName, rawChannel := range channelsMap { + chMap, ok := rawChannel.(map[string]any) + if !ok { + continue + } + + if rawAllowFrom, exists := chMap["allow_from"]; exists { + normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{ + stripHiddenChars: true, + }) + if err != nil { + return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err) + } + chMap["allow_from"] = normalized + } + + if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok { + if rawPrefixes, exists := groupTrigger["prefixes"]; exists { + normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{}) + if err != nil { + return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err) + } + groupTrigger["prefixes"] = normalized + } + } + + settingsMap, hasSettings := asMapField(chMap, "settings") + if !hasSettings { + continue + } + + settingsType := channelSettingsType(defaultCfg, channelName, chMap) + if settingsType == nil { + continue + } + + for i := range settingsType.NumField() { + field := settingsType.Field(i) + if !field.IsExported() || !isStringSliceType(field.Type) { + continue + } + jsonKey := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonKey == "" || jsonKey == "-" { + continue + } + rawValue, exists := settingsMap[jsonKey] + if !exists { + continue + } + + options := stringArrayParserOptions{} + if jsonKey == "allow_from" { + options.stripHiddenChars = true + } + normalized, err := normalizeStringArrayValue(rawValue, options) + if err != nil { + return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err) + } + settingsMap[jsonKey] = normalized + } + } + return nil +} + +func channelSettingsType( + defaultCfg *config.Config, + channelName string, + channelMap map[string]any, +) reflect.Type { + if channelType, _ := channelMap["type"].(string); channelType != "" { + if bc := defaultCfg.Channels.GetByType(channelType); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + } + + if bc := defaultCfg.Channels.Get(channelName); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + + return nil +} + +func derefType(typ reflect.Type) reflect.Type { + for typ != nil && typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + return typ +} + +func isStringSliceType(typ reflect.Type) bool { + typ = derefType(typ) + return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String +} + +func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case string: + return parseStringArrayValue(typed, options), nil + case float64: + return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil + case []string: + return normalizeStringArrayItems(typed, options), nil + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + switch raw := item.(type) { + case string: + items = append(items, raw) + case float64: + items = append(items, fmt.Sprintf("%.0f", raw)) + default: + return nil, fmt.Errorf("unsupported list item type %T", item) + } + } + return normalizeStringArrayItems(items, options), nil + default: + return nil, fmt.Errorf("unsupported list field type %T", value) + } +} + +func parseStringArrayValue(raw string, options stringArrayParserOptions) []string { + if strings.TrimSpace(raw) == "" { + return []string{} + } + splitRe := conservativeSplitRe + if options.stripHiddenChars { + splitRe = allowFromSplitRe + } + return normalizeStringArrayItems(splitRe.Split(raw, -1), options) +} + +func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string { + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + normalized := item + if options.stripHiddenChars { + normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "") + } + normalized = strings.TrimSpace(normalized) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + if len(result) == 0 { + return []string{} + } + return result +} + func getSecretString(m map[string]any, key string) (string, bool) { if raw, exists := m[key]; exists { s, isString := raw.(string) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 083136bce..8377c2eca 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -174,6 +174,409 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes } } +func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "feishu": { + "enabled": true, + "allow_from": ["ou_patch_user"], + "settings": { + "app_id": "cli_patch_app", + "app_secret": "patch-secret", + "is_lark": true + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config 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) + } + bc := cfg.Channels[config.ChannelFeishu] + if !bc.Enabled { + t.Fatal("feishu should be enabled after PATCH") + } + if len(bc.AllowFrom) != 1 || bc.AllowFrom[0] != "ou_patch_user" { + t.Fatalf("feishu allow_from = %#v, want [\"ou_patch_user\"]", bc.AllowFrom) + } + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + feishuCfg := decoded.(*config.FeishuSettings) + if got := feishuCfg.AppID; got != "cli_patch_app" { + t.Fatalf("feishu app_id = %q, want %q", got, "cli_patch_app") + } + if got := feishuCfg.AppSecret.String(); got != "patch-secret" { + t.Fatalf("feishu app_secret = %q, want %q", got, "patch-secret") + } + if !feishuCfg.IsLark { + t.Fatal("feishu is_lark should be true after PATCH") + } +} + +func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "pico": { + "type": "pico", + "allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ", + "group_trigger": { + "prefixes": "/,!;\n?,/" + }, + "settings": { + "allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com" + } + }, + "irc": { + "type": "irc", + "settings": { + "channels": "#ops,\n#dev,\n#ops", + "request_caps": "multi-prefix,echo-message\tbatch,multi-prefix" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config 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) + } + + picoChannel := cfg.Channels[config.ChannelPico] + if len(picoChannel.AllowFrom) != 3 || + picoChannel.AllowFrom[0] != "ou_a" || + picoChannel.AllowFrom[1] != "ou_b" || + picoChannel.AllowFrom[2] != "ou_c" { + t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom) + } + if len(picoChannel.GroupTrigger.Prefixes) != 3 || + picoChannel.GroupTrigger.Prefixes[0] != "/" || + picoChannel.GroupTrigger.Prefixes[1] != "!;" || + picoChannel.GroupTrigger.Prefixes[2] != "?" { + t.Fatalf( + "pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]", + picoChannel.GroupTrigger.Prefixes, + ) + } + + decoded, err := picoChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() pico error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 2 || + picoCfg.AllowOrigins[0] != "https://a.example.com" || + picoCfg.AllowOrigins[1] != "http://localhost:5173" { + t.Fatalf( + "pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]", + picoCfg.AllowOrigins, + ) + } + + ircChannel := cfg.Channels[config.ChannelIRC] + decoded, err = ircChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() irc error = %v", err) + } + ircCfg := decoded.(*config.IRCSettings) + if len(ircCfg.Channels) != 2 || + ircCfg.Channels[0] != "#ops" || + ircCfg.Channels[1] != "#dev" { + t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels) + } + if len(ircCfg.RequestCaps) != 3 || + ircCfg.RequestCaps[0] != "multi-prefix" || + ircCfg.RequestCaps[1] != "echo-message" || + ircCfg.RequestCaps[2] != "batch" { + t.Fatalf( + "irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]", + ircCfg.RequestCaps, + ) + } +} + +func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": 123456 + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config 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) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" { + t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom) + } +} + +func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + tests := []struct { + name string + body string + }{ + { + name: "object allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": {"id": "bad"} + } + } + }`, + }, + { + name: "boolean allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": true + } + } + }`, + }, + { + name: "object settings array", + body: `{ + "channel_list": { + "irc": { + "type": "irc", + "settings": { + "channels": {"name": "#ops"} + } + } + } + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" { + t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom) + } + }) + } +} + +func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel := cfg.Channels[config.ChannelFeishu] + feishuChannel.Enabled = true + feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"} + decoded, err := feishuChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + feishuCfg := decoded.(*config.FeishuSettings) + feishuCfg.AppID = "cli_existing_app" + feishuCfg.AppSecret = *config.NewSecureString("existing-secret") + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "feishu": { + "enabled": true, + "allow_from": "", + "settings": { + "app_id": "cli_existing_app" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config 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) + } + feishuChannel = cfg.Channels[config.ChannelFeishu] + if len(feishuChannel.AllowFrom) != 0 { + t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom) + } + + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if strings.Contains(string(configData), `"allow_from": [""]`) { + t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData)) + } +} + +func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + delete(cfg.Channels, config.ChannelIRC) + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "irc": { + "enabled": true, + "type": "irc", + "settings": { + "server": "irc.example.com", + "password": "irc-patch-password" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config 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) + } + bc := cfg.Channels[config.ChannelIRC] + if bc == nil { + t.Fatal("irc channel should exist after PATCH") + } + if got := bc.Type; got != config.ChannelIRC { + t.Fatalf("irc type = %q, want %q", got, config.ChannelIRC) + } + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + ircCfg := decoded.(*config.IRCSettings) + if got := ircCfg.Server; got != "irc.example.com" { + t.Fatalf("irc server = %q, want %q", got, "irc.example.com") + } + if got := ircCfg.Password.String(); got != "irc-patch-password" { + t.Fatalf("irc password = %q, want %q", got, "irc-patch-password") + } + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if bytes.Contains(configData, []byte("irc-patch-password")) { + t.Fatalf("config file leaked irc password: %s", string(configData)) + } +} + // setupPicoEnabledEnv creates a test environment with Pico channel enabled and // its token stored only in .security.yml (not in the JSON payload). func setupPicoEnabledEnv(t *testing.T) (string, func()) { diff --git a/web/backend/api/exec_nonwindows.go b/web/backend/api/exec_nonwindows.go new file mode 100644 index 000000000..0dc3c0e94 --- /dev/null +++ b/web/backend/api/exec_nonwindows.go @@ -0,0 +1,11 @@ +//go:build !windows + +package api + +import "os/exec" + +func launcherExecCommand(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) +} + +func applyLauncherProcAttrs(_ *exec.Cmd) {} diff --git a/web/backend/api/exec_windows.go b/web/backend/api/exec_windows.go new file mode 100644 index 000000000..86d3193a0 --- /dev/null +++ b/web/backend/api/exec_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package api + +import ( + "os/exec" + "syscall" +) + +func launcherExecCommand(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + applyLauncherProcAttrs(cmd) + return cmd +} + +func applyLauncherProcAttrs(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index fa5652323..67b055236 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -2,6 +2,7 @@ package api import ( "bufio" + "bytes" "encoding/json" "errors" "fmt" @@ -10,14 +11,15 @@ import ( "net/http" "os" "os/exec" + "reflect" "runtime" + "sort" "strconv" "strings" "sync" "syscall" "time" - "github.com/sipeed/picoclaw/pkg/channels/pico" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" @@ -37,28 +39,12 @@ var gateway = struct { startupDeadline time.Time logs *LogBuffer pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json - picoToken string // cached pico token from config (for proxy auth validation) + picoToken string // cached raw pico token for upstream gateway proxy injection }{ runtimeStatus: "stopped", logs: NewLogBuffer(200), } -// refreshPicoToken updates gateway.picoToken from cfg -func refreshPicoToken(cfg *config.Config) { - gateway.mu.Lock() - defer gateway.mu.Unlock() - var picoCfg config.PicoSettings - if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { - decoded, err := bc.GetDecoded() - if err == nil && decoded != nil { - if p, ok := decoded.(*config.PicoSettings); ok { - picoCfg = *p - } - } - } - gateway.picoToken = picoCfg.Token.String() -} - // refreshPicoTokensLocked reads the pico token from config and caches it. // Caller must hold gateway.mu (or be sole writer). func refreshPicoTokensLocked(configPath string) { @@ -101,18 +87,15 @@ const ( tokenPrefix = "token." ) -// picoComposedToken returns "pico-"+pidToken+picoToken for gateway auth. -func picoComposedToken(token string) string { +// picoGatewayProtocol returns the gateway-facing pico subprotocol that the +// launcher should inject when proxying browser traffic upstream. +func picoGatewayProtocol() string { gateway.mu.Lock() defer gateway.mu.Unlock() - // if not initial pico token, don't allow gateway auth - if gateway.picoToken == "" || gateway.pidData == nil { + if gateway.picoToken == "" { return "" } - if tokenPrefix+gateway.picoToken != token { - return "" - } - return pico.PicoTokenPrefix + gateway.pidData.Token + gateway.picoToken + return tokenPrefix + gateway.picoToken } var ( @@ -184,7 +167,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { `$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`, pid, ) - out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output() + out, err := launcherExecCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output() if err == nil { cmdline := strings.TrimSpace(string(out)) if cmdline != "" { @@ -193,7 +176,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { } // Fallback: determine only whether the process still exists. - out, err = exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output() + out, err = launcherExecCommand("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output() if err != nil { return false, false } @@ -207,7 +190,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { if strings.Contains(line, "\"picoclaw.exe\"") { return true, true } - return false, false + return false, true } if strings.Contains(line, "no tasks are running") { return false, true @@ -215,7 +198,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { return false, true } - out, err := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() + out, err := launcherExecCommand("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() if err != nil { return false, false } @@ -451,6 +434,10 @@ func computeConfigSignature(cfg *config.Config) string { } if cfg.Tools.Web.Enabled { toolSignatures = append(toolSignatures, "web") + webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web))) + if err == nil { + parts = append(parts, "webcfg:"+string(webConfig)) + } } if cfg.Tools.WebFetch.Enabled { toolSignatures = append(toolSignatures, "web_fetch") @@ -494,9 +481,175 @@ func computeConfigSignature(cfg *config.Config) string { if len(toolSignatures) > 0 { parts = append(parts, "tools:"+strings.Join(toolSignatures, ",")) } + channelSignatures := computeChannelSignatures(cfg.Channels) + if len(channelSignatures) > 0 { + parts = append(parts, "channels:"+strings.Join(channelSignatures, ",")) + } return strings.Join(parts, ";") } +func computeChannelSignatures(channels config.ChannelsConfig) []string { + if len(channels) == 0 { + return nil + } + + keys := make([]string, 0, len(channels)) + for name := range channels { + keys = append(keys, name) + } + sort.Strings(keys) + + signatures := make([]string, 0, len(keys)) + for _, name := range keys { + channel := channels[name] + if channel == nil { + signatures = append(signatures, name+":") + continue + } + + payload := struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id,omitempty"` + GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing config.TypingConfig `json:"typing,omitempty"` + Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` + }{ + Enabled: channel.Enabled, + Type: channel.Type, + AllowFrom: channel.AllowFrom, + ReasoningChannelID: channel.ReasoningChannelID, + GroupTrigger: channel.GroupTrigger, + Typing: channel.Typing, + Placeholder: channel.Placeholder, + Settings: normalizeChannelSettings(channel), + } + + encoded, err := json.Marshal(payload) + if err != nil { + signatures = append(signatures, name+":") + continue + } + signatures = append(signatures, name+":"+string(encoded)) + } + + return signatures +} + +func normalizeChannelSettings(channel *config.Channel) json.RawMessage { + if channel == nil { + return nil + } + + decoded, err := channel.GetDecoded() + if err == nil && decoded != nil { + normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded))) + if err == nil { + return normalized + } + } + + return normalizeRawJSON(channel.Settings) +} + +func normalizeRawJSON(raw config.RawNode) json.RawMessage { + if len(raw) == 0 { + return nil + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return bytes.TrimSpace(raw) + } + + normalized, err := json.Marshal(value) + if err != nil { + return bytes.TrimSpace(raw) + } + return normalized +} + +func canonicalizeSignatureValue(value reflect.Value) any { + if !value.IsValid() { + return nil + } + + if value.CanInterface() { + switch typed := value.Interface().(type) { + case config.SecureString: + return typed.String() + case *config.SecureString: + if typed == nil { + return "" + } + return typed.String() + case config.SecureStrings: + return typed.Values() + case *config.SecureStrings: + if typed == nil { + return nil + } + return typed.Values() + } + } + + switch value.Kind() { + case reflect.Interface, reflect.Pointer: + if value.IsNil() { + return nil + } + return canonicalizeSignatureValue(value.Elem()) + case reflect.Struct: + result := make(map[string]any) + valueType := value.Type() + for i := 0; i < value.NumField(); i++ { + field := valueType.Field(i) + if field.PkgPath != "" { + continue + } + tag := field.Tag.Get("json") + name := field.Name + if tag != "" { + if comma := strings.Index(tag, ","); comma >= 0 { + tag = tag[:comma] + } + if tag == "-" { + continue + } + if tag != "" { + name = tag + } + } + result[name] = canonicalizeSignatureValue(value.Field(i)) + } + return result + case reflect.Slice, reflect.Array: + length := value.Len() + result := make([]any, 0, length) + for i := 0; i < length; i++ { + result = append(result, canonicalizeSignatureValue(value.Index(i))) + } + return result + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return value.Interface() + } + result := make(map[string]any, value.Len()) + iter := value.MapRange() + for iter.Next() { + result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value()) + } + return result + default: + if value.CanInterface() { + return value.Interface() + } + return nil + } +} + func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool { if gatewayStatus != "running" { return false @@ -726,6 +879,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...) + applyLauncherProcAttrs(cmd) cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same @@ -752,7 +906,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) // Non-fatal: gateway can still start without pico channel @@ -761,6 +915,11 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int // Already holding gateway.mu from caller. if changed { refreshPicoTokensLocked(h.configPath) + cfg, err = config.LoadConfig(h.configPath) + if err != nil { + return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err) + } + defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) } if err := cmd.Start(); err != nil { diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index c6c2073e2..03af7a9d3 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -85,8 +85,22 @@ func requestHostName(r *http.Request) string { return netbind.ResolveAdaptiveLoopbackHost() } +func forwardedProtoFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if raw == "" { + raw = forwardedRFC7239Proto(r) + } + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return strings.ToLower(raw) +} + func requestWSScheme(r *http.Request) string { - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "wss" @@ -105,7 +119,7 @@ func requestWSScheme(r *http.Request) string { // requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE). func requestHTTPScheme(r *http.Request) string { - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "https" @@ -117,6 +131,7 @@ func requestHTTPScheme(r *http.Request) string { if r.TLS != nil { return "https" } + return "http" } @@ -138,6 +153,14 @@ func forwardedHostFirst(r *http.Request) string { // forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239). func forwardedRFC7239Host(r *http.Request) string { + return forwardedRFC7239Param(r, "host") +} + +func forwardedRFC7239Proto(r *http.Request) string { + return forwardedRFC7239Param(r, "proto") +} + +func forwardedRFC7239Param(r *http.Request, key string) string { v := strings.TrimSpace(r.Header.Get("Forwarded")) if v == "" { return "" @@ -146,7 +169,7 @@ func forwardedRFC7239Host(r *http.Request) string { for _, part := range strings.Split(first, ";") { part = strings.TrimSpace(part) low := strings.ToLower(part) - if !strings.HasPrefix(low, "host=") { + if !strings.HasPrefix(low, key+"=") { continue } val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:]) @@ -177,13 +200,21 @@ func clientVisiblePort(r *http.Request, serverListenPort int) string { if p := forwardedPortFirst(r); p != "" { return p } + if fwdHost := forwardedHostFirst(r); fwdHost != "" { + if _, port, err := net.SplitHostPort(fwdHost); err == nil && port != "" { + return port + } + } if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { return port } + if strings.TrimSpace(r.Host) == "" && forwardedHostFirst(r) == "" { + return strconv.Itoa(serverListenPort) + } if requestHTTPScheme(r) == "https" { return "443" } - return strconv.Itoa(serverListenPort) + return "80" } // joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser. @@ -205,16 +236,7 @@ func (h *Handler) picoWebUIAddr(r *http.Request) string { if fwdHost := forwardedHostFirst(r); fwdHost != "" { return joinClientVisibleHostPort(r, fwdHost, wsPort) } - host := requestHostName(r) - // Use clientVisiblePort only when an explicit port is present in headers - // or Host header — do not infer from TLS/scheme, as serverPort takes priority. - if p := forwardedPortFirst(r); p != "" { - return net.JoinHostPort(host, p) - } - if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { - return net.JoinHostPort(host, port) - } - return net.JoinHostPort(host, strconv.Itoa(wsPort)) + return joinClientVisibleHostPort(r, requestHostName(r), wsPort) } func (h *Handler) buildWsURL(r *http.Request) string { diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index d0fc26d7b..54d1010d2 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -50,7 +50,7 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) req.Host = "192.168.1.9:18800" if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" { @@ -181,12 +181,12 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") - if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "wss://chat.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:443/pico/ws") } } @@ -198,12 +198,12 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} - if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "wss://secure.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:443/pico/ws") } } @@ -224,7 +224,7 @@ func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/info", nil) req.Host = "127.0.0.1:18800" req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com") req.Header.Set("X-Forwarded-Proto", "https") @@ -249,13 +249,30 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) req.Host = "chat.example.com" req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") - if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "ws://chat.example.com:80/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:80/pico/ws") + } +} + +func TestBuildWsURLDoesNotTrustOriginWhenProxyOmitsForwardedProto(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) + req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" + req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") + + if got := h.buildWsURL(req); got != "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws" { + t.Fatalf( + "buildWsURL() = %q, want %q", + got, + "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws", + ) } } @@ -264,7 +281,7 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { h := NewHandler(configPath) h.SetServerOptions(18800, false, false, nil) - req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/info", nil) req.Host = "localhost:18800" if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" { diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 78bf34a63..1d9352972 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -121,6 +121,18 @@ func resetGatewayTestState(t *testing.T) { }) } +func TestPicoGatewayProtocol(t *testing.T) { + resetGatewayTestState(t) + + gateway.mu.Lock() + gateway.picoToken = "ui-token" + gateway.mu.Unlock() + + if got := picoGatewayProtocol(); got != tokenPrefix+"ui-token" { + t.Fatalf("picoGatewayProtocol() = %q, want %q", got, tokenPrefix+"ui-token") + } +} + type gatewayStartEnvSnapshot struct { GatewayHost string `json:"gateway_host"` GatewayHostSet bool `json:"gateway_host_set"` @@ -274,6 +286,61 @@ func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) } } +func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sleep command differs on Windows") + } + + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + delete(cfg.Channels, "pico") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd { + return exec.Command("sleep", "30") + } + + originalSignature := computeConfigSignature(cfg) + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + t.Fatalf("startGatewayLocked() error = %v", err) + } + if pid <= 0 { + t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid) + } + + gateway.mu.Lock() + cmd := gateway.cmd + bootSignature := gateway.bootConfigSignature + gateway.mu.Unlock() + t.Cleanup(func() { + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + if cmd != nil { + _ = cmd.Wait() + } + }) + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + expectedSignature := computeConfigSignature(updatedCfg) + if expectedSignature == originalSignature { + t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start") + } + if bootSignature != expectedSignature { + t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature) + } +} + func TestGatewayStartReady_NoDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -1096,6 +1163,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) { } } +func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegram := updatedCfg.Channels.Get("telegram") + if telegram == nil { + t.Fatalf("expected default telegram channel config") + } + telegram.Enabled = !telegram.Enabled + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + +func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + cfg.Tools.Web.Enabled = true + cfg.Tools.Web.Provider = "sogou" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Tools.Web.Provider = "duckduckgo" + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) { resetGatewayTestState(t) diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index d16cd9267..92911157c 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -4,16 +4,14 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) type launcherConfigPayload struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs"` - LauncherToken string `json:"launcher_token"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` } func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { @@ -50,10 +48,9 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), - LauncherToken: cfg.LauncherToken, + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } @@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ return } - cfg := launcherconfig.Config{ - Port: payload.Port, - Public: payload.Public, - AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), - LauncherToken: strings.TrimSpace(payload.LauncherToken), + cfg, err := h.loadLauncherConfig() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) + return } + cfg.Port = payload.Port + cfg.Public = payload.Public + cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...) + cfg.LegacyLauncherToken = "" if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), - LauncherToken: cfg.LauncherToken, + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 4e0acf5d0..68ab1be42 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { if got.Port != 19999 || !got.Public { t.Fatalf("response = %+v, want port=19999 public=true", got) } - if got.LauncherToken != "" { - t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken) - } if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs) } @@ -44,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { func TestPutLauncherConfigPersists(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") + path := launcherconfig.PathForAppConfig(configPath) + if err := os.WriteFile( + path, + []byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } h := NewHandler(configPath) mux := http.NewServeMux() @@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) { http.MethodPut, "/api/system/launcher-config", strings.NewReader( - `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`, + `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`, ), ) req.Header.Set("Content-Type", "application/json") @@ -64,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - path := launcherconfig.PathForAppConfig(configPath) cfg, err := launcherconfig.Load(path, launcherconfig.Default()) if err != nil { t.Fatalf("launcherconfig.Load() error = %v", err) @@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) { if cfg.Port != 18080 || !cfg.Public { t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) } - if cfg.LauncherToken != "saved-token" { - t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token") + if cfg.DashboardPasswordHash != "saved-hash" { + t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash) + } + if cfg.LegacyLauncherToken != "" { + t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken) } if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs) diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index 98bd501f5..d262cf124 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -87,7 +87,7 @@ func hasModelConfiguration(m *config.ModelConfig) bool { apiKey := strings.TrimSpace(m.APIKey()) if authMethod == "oauth" || authMethod == "token" { - if provider, ok := oauthProviderForModel(m.Model); ok { + if provider, ok := oauthProviderForModel(m); ok { cred, err := oauthGetCredential(provider) if err != nil || cred == nil { return false @@ -123,7 +123,7 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { return true } - protocol := modelProtocol(m.Model) + protocol := modelProtocol(m) switch protocol { case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": @@ -172,7 +172,7 @@ func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) boo func runLocalModelProbe(m *config.ModelConfig) bool { apiBase := modelProbeAPIBase(m) - protocol, modelID := splitModel(m.Model) + protocol, modelID := splitModel(m) switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) @@ -191,7 +191,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool { } func modelProbeCacheKey(m *config.ModelConfig) string { - protocol, modelID := splitModel(m.Model) + protocol, modelID := splitModel(m) apiBaseRaw := modelProbeAPIBase(m) apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/")) @@ -384,7 +384,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string { return normalizeModelProbeAPIBase(apiBase) } - protocol := modelProtocol(m.Model) + protocol := modelProtocol(m) if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { return providers.DefaultAPIBaseForProtocol(protocol) } @@ -419,8 +419,8 @@ func normalizeModelProbeAPIBase(raw string) string { return u.String() } -func oauthProviderForModel(model string) (string, bool) { - switch modelProtocol(model) { +func oauthProviderForModel(m *config.ModelConfig) (string, bool) { + switch modelProtocol(m) { case "openai": return oauthProviderOpenAI, true case "anthropic": @@ -432,18 +432,14 @@ func oauthProviderForModel(model string) (string, bool) { } } -func modelProtocol(model string) string { - protocol, _ := splitModel(model) +func modelProtocol(m *config.ModelConfig) string { + protocol, _ := splitModel(m) return protocol } -func splitModel(model string) (protocol, modelID string) { - model = strings.ToLower(strings.TrimSpace(model)) - protocol, _, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:]) +func splitModel(m *config.ModelConfig) (protocol, modelID string) { + protocol, modelID = providers.ExtractProtocol(m) + return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID)) } func hasLocalAPIBase(raw string) bool { diff --git a/web/backend/api/models.go b/web/backend/api/models.go index aa4a775eb..cf903ce4c 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -6,10 +6,12 @@ import ( "io" "net/http" "strconv" + "strings" "sync" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" ) // registerModelRoutes binds model list management endpoints to the ServeMux. @@ -26,6 +28,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { type modelResponse struct { Index int `json:"index"` ModelName string `json:"model_name"` + Provider string `json:"provider,omitempty"` Model string `json:"model"` APIBase string `json:"api_base,omitempty"` APIKey string `json:"api_key"` @@ -73,10 +76,12 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { models := make([]modelResponse, 0, len(cfg.ModelList)) for i, m := range cfg.ModelList { + provider, modelID := providers.ExtractProtocol(m) models = append(models, modelResponse{ Index: i, ModelName: m.ModelName, - Model: m.Model, + Provider: provider, + Model: modelID, APIBase: m.APIBase, APIKey: maskAPIKey(m.APIKey()), Proxy: m.Proxy, @@ -176,6 +181,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() + var rawFields map[string]json.RawMessage + if err = json.Unmarshal(body, &rawFields); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + type custom struct { config.ModelConfig APIKey string `json:"api_key"` @@ -226,6 +237,35 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } else if len(mc.CustomHeaders) == 0 { mc.CustomHeaders = nil } + // 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 "". + if _, ok := rawFields["provider"]; !ok { + mc.Provider = cfg.ModelList[idx].Provider + // Older clients still round-trip the legacy model field only. When the + // stored config encodes provider/model in Model and has no explicit + // Provider field yet, continue preserving that hidden provider prefix. + // 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) + if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { + if incomingModel == existingModelID { + mc.Model = existingRawModel + } else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") { + // Older clients never saw the hidden provider prefix for simple + // legacy entries such as "openai/gpt-4o". If they now send an + // explicit provider/model string, treat it as the caller's full + // intent instead of re-applying the old hidden prefix. + mc.Model = incomingModel + } else if !strings.HasPrefix(incomingModel, existingProtocol+"/") { + mc.Model = existingProtocol + "/" + incomingModel + } + } + } + } cfg.ModelList[idx] = &mc.ModelConfig diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index e4297f679..f374ac15b 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -392,6 +392,49 @@ func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) { } } +func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var gotProbe string + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + gotProbe = apiBase + "|" + modelID + "|" + apiKey + return true + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "vllm-local", + Provider: "vllm", + Model: "custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }} + 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()) + } + + if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { + t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") + } +} + func TestHandleAddModel_PersistsAPIKey(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -430,6 +473,76 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) { } } +func TestHandleAddModel_PersistsProvider(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":"nvidia-glm", + "provider":"nvidia", + "model":"z-ai/glm-5.1", + "api_key":"nv-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()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if added.Provider != "nvidia" { + t.Fatalf("provider = %q, want %q", added.Provider, "nvidia") + } + if added.Model != "z-ai/glm-5.1" { + t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1") + } +} + +func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(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":"openai-gpt", + "provider":"openai", + "model":"openai/gpt-4o-mini", + "api_key":"sk-openai" + }`)) + 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 != "openai" { + t.Fatalf("provider = %q, want %q", got, "openai") + } + if got := added.Model; got != "openai/gpt-4o-mini" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-4o-mini") + } +} + func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -536,6 +649,370 @@ func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) { } } +func TestHandleUpdateModel_PersistsProvider(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: "gpt-4o", + Provider: "openai", + }} + 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":"editable", + "provider":"openrouter", + "model":"openai/gpt-4o" + }`)) + 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 != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } +} + +func TestHandleUpdateModel_PreservesExplicitProviderPrefixedModel(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: "gpt-4o", + Provider: "openai", + }} + 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":"editable", + "provider":"openai", + "model":"openai/gpt-5.4" + }`)) + 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 != "openai" { + t.Fatalf("provider = %q, want %q", got, "openai") + } + if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } +} + +func TestHandleListModels_PreservesExplicitProviderPrefixedModel(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: "openrouter-auto-explicit", + Provider: "openrouter", + Model: "openrouter/auto", + }} + 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 != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := resp.Models[0].Model; got != "openrouter/auto" { + t.Fatalf("model = %q, want %q", got, "openrouter/auto") + } +} + +func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(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", + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Simulate an older client: it reads GET /api/models, ignores the new + // provider field, then PUTs the visible model string back unchanged. + 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 != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := listResp.Models[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"legacy-openrouter", + "model":"openai/gpt-5.4" + }`)) + 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 != "" { + t.Fatalf("provider = %q, want empty", got) + } + if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4") + } +} + +func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(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", + }} + 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":"legacy-openrouter", + "model":"openai/gpt-5.5" + }`)) + 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 != "" { + t.Fatalf("provider = %q, want empty", got) + } + if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" { + t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5") + } +} + +func TestHandleListModels_ReturnsProviderField(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: "nvidia-glm", + Provider: "nvidia", + Model: "z-ai/glm-5.1", + APIKeys: config.SimpleSecureStrings("nv-key"), + }} + 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 != "nvidia" { + t.Fatalf("provider = %q, want %q", got, "nvidia") + } +} + +func TestHandleListModels_ReturnsEffectiveProviderField(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: "plain-openai", + Model: "gpt-4o", + }, + { + ModelName: "explicit-google", + Provider: "google", + Model: "gemini-2.5-pro", + }, + { + ModelName: "explicit-qwen-intl", + Provider: "qwen-international", + Model: "qwen3-coder-plus", + }, + } + 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) != 3 { + t.Fatalf("len(models) = %d, want 3", len(resp.Models)) + } + + if got := resp.Models[0].Provider; got != "openai" { + t.Fatalf("provider[0] = %q, want %q", got, "openai") + } + if got := resp.Models[0].Model; got != "gpt-4o" { + t.Fatalf("model[0] = %q, want %q", got, "gpt-4o") + } + if got := resp.Models[1].Provider; got != "gemini" { + t.Fatalf("provider[1] = %q, want %q", got, "gemini") + } + if got := resp.Models[1].Model; got != "gemini-2.5-pro" { + t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro") + } + if got := resp.Models[2].Provider; got != "qwen-intl" { + t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl") + } + if got := resp.Models[2].Model; got != "qwen3-coder-plus" { + t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus") + } +} + // TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent // model as default returns 404. This covers the case where virtual models (which are // filtered by SaveConfig) cannot be set as default. diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 213b53836..116e304b1 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { found := false for i := range cfg.ModelList { - if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { + if modelBelongsToProvider(provider, cfg.ModelList[i]) { cfg.ModelList[i].AuthMethod = authMethod found = true } @@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { return oauthSaveConfig(h.configPath, cfg) } -func modelBelongsToProvider(provider, model string) bool { - lower := strings.ToLower(strings.TrimSpace(model)) +func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool { + protocol, _ := providers.ExtractProtocol(modelCfg) switch provider { case oauthProviderOpenAI: - return lower == "openai" || strings.HasPrefix(lower, "openai/") + return protocol == "openai" case oauthProviderAnthropic: - return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/") + return protocol == "anthropic" case oauthProviderGoogleAntigravity: - return lower == "antigravity" || - lower == "google-antigravity" || - strings.HasPrefix(lower, "antigravity/") || - strings.HasPrefix(lower, "google-antigravity/") + return protocol == "antigravity" || protocol == "google-antigravity" default: return false } @@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon case oauthProviderOpenAI: return &config.ModelConfig{ ModelName: "gpt-5.4", - Model: "openai/gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", AuthMethod: authMethod, } case oauthProviderAnthropic: return &config.ModelConfig{ ModelName: "claude-sonnet-4.6", - Model: "anthropic/claude-sonnet-4.6", + Provider: "anthropic", + Model: "claude-sonnet-4.6", AuthMethod: authMethod, } case oauthProviderGoogleAntigravity: return &config.ModelConfig{ ModelName: "gemini-flash", - Model: "antigravity/gemini-3-flash", + Provider: "antigravity", + Model: "gemini-3-flash", AuthMethod: authMethod, } default: diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go index 5aaff8d8f..9468c8873 100644 --- a/web/backend/api/oauth_test.go +++ b/web/backend/api/oauth_test.go @@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { } } +func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: "gpt-5.4", + Provider: "openai", + Model: "gpt-5.4", + AuthMethod: "oauth", + }) + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "token-before-logout", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential error: %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`)) + 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[len(updated.ModelList)-1].AuthMethod; got != "" { + t.Fatalf("auth_method = %q, want empty", got) + } +} + func setupOAuthTestEnv(t *testing.T) (string, func()) { t.Helper() diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 00ffb8bb2..8eeff4041 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -16,7 +16,7 @@ import ( // registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken) + mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) @@ -24,16 +24,21 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { // This allows the frontend to connect via the same port as the web UI, // avoiding the need to expose extra ports for WebSocket communication. mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy()) + mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy()) + mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy()) } // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // The gateway bind host and port are resolved from the latest configuration. -func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.ReverseProxy { +func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy { wsProxy := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { target := h.gatewayProxyURL() r.SetURL(target) - r.Out.Header.Set(protocolKey, tokenPrefix+token) + r.Out.Header.Del(protocolKey) + if upstreamProtocol != "" { + r.Out.Header.Set(protocolKey, upstreamProtocol) + } }, ModifyResponse: func(r *http.Response) error { if prot := r.Header.Values(protocolKey); len(prot) > 0 { @@ -52,90 +57,158 @@ func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.Rev return wsProxy } +func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + target := h.gatewayProxyURL() + r.SetURL(target) + r.Out.Header.Set("Authorization", "Bearer "+token) + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Errorf("Failed to proxy Pico HTTP request: %v", err) + http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) + }, + } +} + +func (h *Handler) gatewayAvailableForProxy() bool { + gateway.mu.Lock() + ensurePicoTokenCachedLocked(h.configPath) + cachedPID := gateway.pidData + trackedCmd := gateway.cmd + gateway.mu.Unlock() + + if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil { + gateway.mu.Lock() + gateway.pidData = pidData + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + return true + } + + if cachedPID == nil { + return false + } + + if isCmdProcessAliveLocked(trackedCmd) { + return true + } + + gateway.mu.Lock() + if gateway.cmd == trackedCmd { + gateway.pidData = nil + setGatewayRuntimeStatusLocked("stopped") + } + available := gateway.pidData != nil + gateway.mu.Unlock() + return available +} + +func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) { + if cfg == nil { + return config.PicoSettings{}, false + } + + bc := cfg.Channels.GetByType(config.ChannelPico) + if bc == nil { + return config.PicoSettings{}, false + } + + var picoCfg config.PicoSettings + if err := bc.Decode(&picoCfg); err != nil { + return config.PicoSettings{}, false + } + + return picoCfg, bc.Enabled +} + +func (h *Handler) writePicoInfoResponse( + w http.ResponseWriter, + r *http.Request, + cfg *config.Config, + changed *bool, +) { + picoCfg, enabled := decodePicoSettings(cfg) + + resp := map[string]any{ + "ws_url": h.buildWsURL(r), + "enabled": enabled, + } + if changed != nil { + resp["changed"] = *changed + } + if picoCfg.Token.String() != "" { + resp["configured"] = true + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + // handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. -// It validates the client token before forwarding; rejects immediately on failure. +// It relies on launcher dashboard auth, then injects the raw pico token only +// on the upstream gateway request. func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - gateway.mu.Lock() - ensurePicoTokenCachedLocked(h.configPath) - cachedPID := gateway.pidData - trackedCmd := gateway.cmd - gateway.mu.Unlock() - - gatewayAvailable := false - // Prefer fresh PID file data when available. - if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil { - gateway.mu.Lock() - gateway.pidData = pidData - setGatewayRuntimeStatusLocked("running") - gatewayAvailable = true - gateway.mu.Unlock() - } else if cachedPID != nil { - // No PID file now: keep availability only while tracked process is - // still alive (covers short PID-file races at startup/restart). - if isCmdProcessAliveLocked(trackedCmd) { - gatewayAvailable = true - } else { - gateway.mu.Lock() - if gateway.cmd == trackedCmd { - gateway.pidData = nil - setGatewayRuntimeStatusLocked("stopped") - } - gatewayAvailable = gateway.pidData != nil - gateway.mu.Unlock() - } - } - - if !gatewayAvailable { + if !h.gatewayAvailableForProxy() { logger.Warnf("Gateway not available for WebSocket proxy") http.Error(w, "Gateway not available", http.StatusServiceUnavailable) return } - prot := r.Header.Values(protocolKey) - if len(prot) > 0 { - origProtocol := prot[0] - newToken := picoComposedToken(prot[0]) - if newToken != "" { - h.createWsProxy(origProtocol, newToken).ServeHTTP(w, r) - return - } + + upstreamProtocol := picoGatewayProtocol() + if upstreamProtocol == "" { + logger.Warn("Pico token unavailable for WebSocket proxy") + http.Error(w, "Pico channel not configured", http.StatusServiceUnavailable) + return } - logger.Warnf("Invalid Pico token: %v", prot) - http.Error(w, "Invalid Pico token", http.StatusForbidden) + var origProtocol string + if prot := r.Header.Values(protocolKey); len(prot) > 0 { + origProtocol = prot[0] + } + + h.createWsProxy(origProtocol, upstreamProtocol).ServeHTTP(w, r) } } -// handleGetPicoToken returns the current WS token and URL for the frontend. +func (h *Handler) handlePicoMediaProxy() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !h.gatewayAvailableForProxy() { + logger.Warnf("Gateway not available for Pico media proxy") + http.Error(w, "Gateway not available", http.StatusServiceUnavailable) + return + } + + gateway.mu.Lock() + picoToken := gateway.picoToken + gateway.mu.Unlock() + + if picoToken == "" { + logger.Warnf("Missing Pico token for media proxy") + http.Error(w, "Invalid Pico token", http.StatusForbidden) + return + } + + h.createPicoHTTPProxy(picoToken).ServeHTTP(w, r) + } +} + +// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI. // -// GET /api/pico/token -func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { +// GET /api/pico/info +func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - bc := cfg.Channels.GetByType(config.ChannelPico) - var picoCfg config.PicoSettings - if bc != nil { - bc.Decode(&picoCfg) - } - enabled := false - if bc != nil { - enabled = bc.Enabled - } - json.NewEncoder(w).Encode(map[string]any{ - "token": picoCfg.Token.String(), - "ws_url": wsURL, - "enabled": enabled, - }) + h.writePicoInfoResponse(w, r, cfg, nil) } -// handleRegenPicoToken generates a new Pico WebSocket token and saves it. +// handleRegenPicoToken rotates the raw Pico WebSocket token and returns +// non-secret connection info for the launcher UI. // // POST /api/pico/token func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { @@ -160,28 +233,16 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token. gateway.mu.Lock() gateway.picoToken = token gateway.mu.Unlock() - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": token, - "ws_url": wsURL, - }) + h.writePicoInfoResponse(w, r, cfg, nil) } // EnsurePicoChannel enables the Pico channel with sane defaults if it isn't // already configured. Returns true when the config was modified. -// -// callerOrigin is the Origin header from the setup request. If non-empty and -// no origins are configured yet, it's written as the allowed origin so the -// WebSocket handshake works for whatever host the caller is on (LAN, custom -// port, etc.). Pass "" when there's no request context. -func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { +func (h *Handler) EnsurePicoChannel() (bool, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, fmt.Errorf("failed to load config: %w", err) @@ -206,12 +267,6 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { picoCfg.Token = *config.NewSecureString(generateSecureToken()) changed = true } - - // Seed origins from the request instead of hardcoding ports. - if len(picoCfg.AllowOrigins) == 0 && callerOrigin != "" { - picoCfg.AllowOrigins = []string{callerOrigin} - changed = true - } } } @@ -228,37 +283,20 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { - changed, err := h.EnsurePicoChannel(r.Header.Get("Origin")) + changed, err := h.EnsurePicoChannel() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Reload config (EnsurePicoChannel may have modified it) and refresh cache. + // Reload config (EnsurePicoChannel may have modified it). cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } - if changed { - refreshPicoToken(cfg) - } - wsURL := h.buildWsURL(r) - - var picoCfg2 config.PicoSettings - if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { - if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { - picoCfg2 = *decoded.(*config.PicoSettings) - } - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": picoCfg2.Token.String(), - "ws_url": wsURL, - "enabled": true, - "changed": changed, - }) + h.writePicoInfoResponse(w, r, cfg, &changed) } // generateSecureToken creates a random 32-character hex string. diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 807c796dc..6f7cefd4d 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -9,18 +9,24 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" - "github.com/sipeed/picoclaw/pkg/channels/pico" "github.com/sipeed/picoclaw/pkg/config" ppid "github.com/sipeed/picoclaw/pkg/pid" ) +func newPicoProxyRequest(method, path string) *http.Request { + req := httptest.NewRequest(method, "http://launcher.local:18800"+path, nil) + req.Header.Set("Origin", "http://launcher.local:18800") + return req +} + func TestEnsurePicoChannel_FreshConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -51,7 +57,7 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -71,11 +77,11 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { } } -func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { +func TestEnsurePicoChannel_LeavesAllowOriginsEmptyByDefault(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -90,45 +96,16 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - for _, origin := range picoCfg.AllowOrigins { - if origin == "*" { - t.Error("setup must not set wildcard origin '*'") - } - } -} - -func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - if _, err := h.EnsurePicoChannel(""); err != nil { - t.Fatalf("EnsurePicoChannel() error = %v", err) - } - - cfg, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig() error = %v", err) - } - - bc := cfg.Channels["pico"] - decoded, err := bc.GetDecoded() - if err != nil { - t.Fatalf("GetDecoded() error = %v", err) - } - picoCfg := decoded.(*config.PicoSettings) - // Without a caller origin, allow_origins stays empty (CheckOrigin - // allows all when the list is empty, so the channel still works). if len(picoCfg.AllowOrigins) != 0 { - t.Errorf("allow_origins = %v, want empty when no caller origin", picoCfg.AllowOrigins) + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } -func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { +func TestEnsurePicoChannel_NoOriginConfigurationRequired(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - lanOrigin := "http://192.168.1.9:18800" - if _, err := h.EnsurePicoChannel(lanOrigin); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -143,8 +120,8 @@ func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != lanOrigin { - t.Errorf("allow_origins = %v, want [%s]", picoCfg.AllowOrigins, lanOrigin) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } @@ -169,7 +146,7 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -213,7 +190,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -253,7 +230,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { } h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -280,10 +257,8 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - origin := "http://localhost:18800" - // First call sets things up - if _, err := h.EnsurePicoChannel(origin); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("first EnsurePicoChannel() error = %v", err) } @@ -297,7 +272,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { token1 := picoCfg.Token.String() // Second call should be a no-op - changed, err := h.EnsurePicoChannel(origin) + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("second EnsurePicoChannel() error = %v", err) } @@ -317,7 +292,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } } -func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { +func TestHandlePicoSetup_DoesNotPersistRequestOrigin(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -342,8 +317,8 @@ func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != "http://10.0.0.5:3000" { - t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", picoCfg.AllowOrigins) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } @@ -365,8 +340,8 @@ func TestHandlePicoSetup_Response(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - if resp["token"] == nil || resp["token"] == "" { - t.Error("response should contain a non-empty token") + if _, ok := resp["token"]; ok { + t.Error("response must not expose the raw pico token") } if resp["ws_url"] == nil || resp["ws_url"] == "" { t.Error("response should contain ws_url") @@ -377,6 +352,97 @@ func TestHandlePicoSetup_Response(t *testing.T) { if resp["changed"] != true { t.Error("response should have changed=true on first setup") } + if resp["configured"] != true { + t.Error("response should have configured=true") + } +} + +func TestHandleGetPicoInfo_OmitsToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/info", nil) + rec := httptest.NewRecorder() + + h.handleGetPicoInfo(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if _, ok := resp["token"]; ok { + t.Fatal("info response must not expose the raw pico token") + } + if resp["enabled"] != true { + t.Fatalf("enabled = %#v, want true", resp["enabled"]) + } + if resp["configured"] != true { + t.Fatalf("configured = %#v, want true", resp["configured"]) + } + if resp["ws_url"] == nil || resp["ws_url"] == "" { + t.Fatal("response should contain ws_url") + } +} + +func TestHandleRegenPicoToken_RefreshesGatewayTokenCache(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.mu.Lock() + gateway.picoToken = origPicoToken + gateway.mu.Unlock() + }) + + gateway.mu.Lock() + gateway.picoToken = "stale-token" + gateway.mu.Unlock() + + req := httptest.NewRequest(http.MethodPost, "http://launcher.local/api/pico/token", nil) + rec := httptest.NewRecorder() + h.handleRegenPicoToken(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + token := decoded.(*config.PicoSettings).Token.String() + if token == "" { + t.Fatal("expected regenerated pico token to be persisted") + } + if token == "stale-token" { + t.Fatal("expected regenerated pico token to differ from stale cache") + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.picoToken != token { + t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, token) + } } func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { @@ -438,20 +504,10 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { gateway.pidData = &ppid.PidFileData{} gateway.picoToken = "pico" - req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req1.Header.Set(protocolKey, tokenPrefix+"wrong_token") + req1 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec1 := httptest.NewRecorder() handler(rec1, req1) - if rec1.Code != http.StatusForbidden { - t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusForbidden) - } - - req1 = httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req1.Header.Set(protocolKey, tokenPrefix+"pico") - rec1 = httptest.NewRecorder() - handler(rec1, req1) - if rec1.Code != http.StatusOK { t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK) } @@ -464,8 +520,7 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { t.Fatalf("SaveConfig() error = %v", err) } - req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req2.Header.Set(protocolKey, tokenPrefix+"pico") + req2 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec2 := httptest.NewRecorder() handler(rec2, req2) @@ -539,8 +594,7 @@ func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) { gateway.pidData = &ppid.PidFileData{} gateway.picoToken = "" - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"cached-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -625,8 +679,7 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { setGatewayRuntimeStatusLocked("stopped") gateway.mu.Unlock() - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"ui-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -634,7 +687,7 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } - expected := tokenPrefix + pico.PicoTokenPrefix + pidData.Token + "ui-token" + expected := tokenPrefix + "ui-token" if got := rec.Body.String(); got != expected { t.Fatalf("forwarded protocol = %q, want %q", got, expected) } @@ -649,6 +702,125 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { } } +func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18790 + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + proxy := h.createPicoHTTPProxy("ui-token") + var capturedPath string + var capturedAuth string + proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + capturedPath = req.URL.Path + capturedAuth = req.Header.Get("Authorization") + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("proxied")), + Request: req, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if capturedPath != "/pico/media/attachment-1" { + t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1") + } + expected := "Bearer ui-token" + if capturedAuth != expected { + t.Fatalf("Authorization = %q, want %q", capturedAuth, expected) + } +} + +func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) { + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handlePicoMediaProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/media/attachment-1" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/media/attachment-1") + } + if got := r.Header.Get("Authorization"); got != "Bearer ui-token" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer ui-token") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied-media") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + origCmd := gateway.cmd + t.Cleanup(func() { + gateway.mu.Lock() + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + gateway.cmd = origCmd + gateway.mu.Unlock() + }) + + gateway.mu.Lock() + gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid} + gateway.picoToken = "ui-token" + gateway.cmd = cmd + gateway.mu.Unlock() + + req := newPicoProxyRequest(http.MethodGet, "/pico/media/attachment-1") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "proxied-media" { + t.Fatalf("body = %q, want %q", body, "proxied-media") + } +} + func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) @@ -696,8 +868,7 @@ func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { setGatewayRuntimeStatusLocked("running") gateway.mu.Unlock() - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"ui-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -711,6 +882,78 @@ func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { } } +func TestHandleWebSocketProxy_AllowsArbitraryOrigin(t *testing.T) { + origMatcher := gatewayProcessMatcher + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + t.Cleanup(func() { gatewayProcessMatcher = origMatcher }) + + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: cfg.Gateway.Host, + Port: cfg.Gateway.Port, + }) + t.Cleanup(func() { + ppid.RemovePidFile(globalConfigDir()) + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "ui-token" + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws?session_id=test-session", nil) + req.Header.Set("Origin", "http://evil.example") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + func mustGatewayTestPort(t *testing.T, rawURL string) int { t.Helper() @@ -726,3 +969,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int { return port } + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index f4ac78ab4..76f63607e 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -89,7 +89,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Skills and tools support/actions h.registerSkillRoutes(mux) h.registerToolRoutes(mux) - h.registerUIRoutes(mux) // OS startup / launch-at-login h.registerStartupRoutes(mux) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 054b78b73..cc18ee6e1 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -46,9 +47,19 @@ type sessionListItem struct { } type sessionChatMessage struct { - Role string `json:"role"` - Content string `json:"content"` - Media []string `json:"media,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + Kind string `json:"kind,omitempty"` + Media []string `json:"media,omitempty"` + Attachments []sessionChatAttachment `json:"attachments,omitempty"` + ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"` +} + +type sessionChatAttachment struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` } // legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL @@ -145,6 +156,9 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag if err := json.Unmarshal(line, &msg); err != nil { continue } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } msgs = append(msgs, msg) } if err := scanner.Err(); err != nil { @@ -398,10 +412,12 @@ func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessio } func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem { + transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) + preview := "" - for _, msg := range sess.Messages { + for _, msg := range transcript { if msg.Role == "user" { - preview = sessionMessagePreview(msg) + preview = sessionChatMessagePreview(msg) } if preview != "" { break @@ -414,13 +430,11 @@ func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArg } title := preview - validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)) - return sessionListItem{ ID: sessionID, Title: title, Preview: preview, - MessageCount: validMessageCount, + MessageCount: len(transcript), Created: sess.Created.Format(time.RFC3339), Updated: sess.Updated.Format(time.RFC3339), } @@ -441,124 +455,267 @@ func truncateRunes(s string, maxLen int) string { return string(runes[:maxLen]) + "..." } -func sessionMessageVisible(msg providers.Message) bool { - return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 +func sessionChatMessageVisible(msg sessionChatMessage) bool { + return strings.TrimSpace(msg.Content) != "" || + len(msg.Media) > 0 || + len(msg.Attachments) > 0 || + len(msg.ToolCalls) > 0 } -func sessionMessagePreview(msg providers.Message) string { +func sessionChatMessagePreview(msg sessionChatMessage) string { if content := strings.TrimSpace(msg.Content); content != "" { return content } + if len(msg.Attachments) > 0 { + if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") { + return "[image]" + } + return "[attachment]" + } if len(msg.Media) > 0 { - return "[image]" + if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") { + return "[image]" + } + return "[attachment]" + } + if len(msg.ToolCalls) > 0 { + return "[tool call]" } return "" } func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage { + return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, false) +} + +func detailSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage { + return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, true) +} + +func sessionTranscriptMessages( + messages []providers.Message, + toolFeedbackMaxArgsLength int, + includeThoughts bool, +) []sessionChatMessage { transcript := make([]sessionChatMessage, 0, len(messages)) for _, msg := range messages { + attachments := sessionAttachments(msg) + switch msg.Role { + case "tool": + continue + case "user": - if sessionMessageVisible(msg) { - transcript = append(transcript, sessionChatMessage{ - Role: "user", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), - }) + chatMsg := sessionChatMessage{ + Role: "user", + Content: msg.Content, + Media: append([]string(nil), msg.Media...), + Attachments: attachments, + } + if sessionChatMessageVisible(chatMsg) { + transcript = append(transcript, chatMsg) } case "assistant": - // Reasoning-only assistant messages are transient display artifacts and - // should not be restored from session history. - if assistantMessageTransientThought(msg) { + if messageutil.IsTransientAssistantThoughtMessage(msg) { continue } - - toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength) - if len(toolSummaryMessages) > 0 { - transcript = append(transcript, toolSummaryMessages...) + if includeThoughts { + if thoughtMsg, ok := assistantThoughtMessage(msg); ok { + transcript = append(transcript, thoughtMsg) + } } + toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage( + msg.ToolCalls, + toolFeedbackMaxArgsLength, + ) visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls) - if len(visibleToolMessages) > 0 { - transcript = append(transcript, visibleToolMessages...) - } // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed // internal summary that marks handled tool delivery. - if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) { + content := msg.Content + if assistantMessageInternalOnly(msg) { + if len(attachments) == 0 { + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } + continue + } + content = "" + } + if hasToolCallsMsg && utils.ToolCallExplanationDuplicatesContent(content, msg.ToolCalls) { + content = "" + } + + chatMsg := sessionChatMessage{ + Role: "assistant", + Content: content, + Media: append([]string(nil), msg.Media...), + Attachments: attachments, + } + if !sessionChatMessageVisible(chatMsg) { + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } continue } - transcript = append(transcript, sessionChatMessage{ - Role: "assistant", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), - }) + transcript = append(transcript, chatMsg) + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } } } - return transcript + return filterSessionChatMessages(transcript) } -func assistantMessageTransientThought(msg providers.Message) bool { - return strings.TrimSpace(msg.Content) == "" && - strings.TrimSpace(msg.ReasoningContent) != "" && - len(msg.ToolCalls) == 0 && - len(msg.Media) == 0 +func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage { + filtered := messages[:0] + for _, msg := range messages { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + filtered = append(filtered, msg) + } + return filtered +} + +func sessionAttachments(msg providers.Message) []sessionChatAttachment { + if len(msg.Attachments) == 0 { + return nil + } + + attachments := make([]sessionChatAttachment, 0, len(msg.Attachments)) + for _, attachment := range msg.Attachments { + urlValue, ok := sessionAttachmentURL(attachment) + if !ok { + continue + } + attachmentType := strings.TrimSpace(attachment.Type) + if attachmentType == "" { + attachmentType = sessionAttachmentType(attachment) + } + attachments = append(attachments, sessionChatAttachment{ + Type: attachmentType, + URL: urlValue, + Filename: strings.TrimSpace(attachment.Filename), + ContentType: strings.TrimSpace(attachment.ContentType), + }) + } + + if len(attachments) == 0 { + return nil + } + return attachments +} + +func sessionAttachmentURL(attachment providers.Attachment) (string, bool) { + if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" { + return rawURL, true + } + + ref := strings.TrimSpace(attachment.Ref) + if ref == "" { + return "", false + } + if strings.HasPrefix(ref, "media://") { + // Persisted session history must only expose durable attachment locations. + // media:// refs depend on the live in-memory MediaStore and may stop + // resolving after a restart or cleanup, so omit them from reopened history. + return "", false + } + return ref, true +} + +func sessionAttachmentType(attachment providers.Attachment) string { + contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType)) + filename := strings.ToLower(strings.TrimSpace(attachment.Filename)) + rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref)) + rawURL := strings.ToLower(strings.TrimSpace(attachment.URL)) + + switch { + case strings.HasPrefix(contentType, "image/"), + strings.HasPrefix(rawRef, "data:image/"), + strings.HasPrefix(rawURL, "data:image/"): + return "image" + case strings.HasPrefix(contentType, "audio/"): + return "audio" + case strings.HasPrefix(contentType, "video/"): + return "video" + } + + switch ext := filepath.Ext(filename); ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + default: + return "file" + } } func assistantMessageInternalOnly(msg providers.Message) bool { return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText } -func visibleAssistantToolSummaryMessages( +func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) { + reasoning := strings.TrimSpace(msg.ReasoningContent) + if reasoning == "" { + return sessionChatMessage{}, false + } + if reasoning == strings.TrimSpace(msg.Content) { + return sessionChatMessage{}, false + } + return sessionChatMessage{ + Role: "assistant", + Content: reasoning, + Kind: "thought", + }, true +} + +func assistantToolCallsMessage( toolCalls []providers.ToolCall, toolFeedbackMaxArgsLength int, -) []sessionChatMessage { +) (sessionChatMessage, bool) { if len(toolCalls) == 0 { - return nil + return sessionChatMessage{}, false } if toolFeedbackMaxArgsLength <= 0 { toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength() } - messages := make([]sessionChatMessage, 0, len(toolCalls)) - for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments - } - - if strings.TrimSpace(name) == "" { - continue - } - - if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { - if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encodedArgs) - } - } - - argsPreview := strings.TrimSpace(argsJSON) - if argsPreview == "" { - argsPreview = "{}" - } - - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)), - }) + visibleToolCalls := utils.BuildVisibleToolCalls(toolCalls, toolFeedbackMaxArgsLength) + if len(visibleToolCalls) == 0 { + return sessionChatMessage{}, false } - return messages + return sessionChatMessage{ + Role: "assistant", + Kind: "tool_calls", + ToolCalls: visibleToolCalls, + }, true +} + +func visibleAssistantToolArgsPreview( + tc providers.ToolCall, + toolFeedbackMaxArgsLength int, +) string { + return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength) } func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { @@ -568,36 +725,36 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments + name, argsJSON := utils.VisibleToolCallNameAndArguments(tc) + if name != "message" { + continue } - - switch name { - case "message": - var args struct { - Content string `json:"content"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - continue - } - if strings.TrimSpace(args.Content) == "" { - continue - } - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: args.Content, - }) + content, ok := parseMessageToolContent(argsJSON) + if !ok { + continue } + messages = append(messages, sessionChatMessage{ + Role: "assistant", + Content: content, + }) } return messages } +func parseMessageToolContent(argsJSON string) (string, bool) { + var args struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", false + } + if strings.TrimSpace(args.Content) == "" { + return "", false + } + return args.Content, true +} + // sessionsDir resolves the path to the gateway's session storage directory. // It reads the workspace from config, falling back to ~/.picoclaw/workspace. func (h *Handler) sessionsDir() (string, error) { @@ -761,7 +918,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { } } - messages := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) + messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index e40a8c77c..760935db7 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/memory" @@ -31,6 +32,25 @@ func sessionsTestDir(t *testing.T, configPath string) string { return dir } +func assertVisibleToolCallMessage( + t *testing.T, + msg sessionChatMessage, + toolName string, +) utils.VisibleToolCall { + t.Helper() + + if msg.Role != "assistant" || msg.Kind != "tool_calls" { + t.Fatalf("message = %#v, want assistant/tool_calls", msg) + } + if len(msg.ToolCalls) != 1 { + t.Fatalf("len(message.ToolCalls) = %d, want 1", len(msg.ToolCalls)) + } + if got := msg.ToolCalls[0].Function; got == nil || got.Name != toolName { + t.Fatalf("tool call = %#v, want function %q", msg.ToolCalls[0], toolName) + } + return msg.ToolCalls[0] +} + func TestHandleListSessions_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -101,6 +121,64 @@ func TestHandleListSessions_JSONLStorage(t *testing.T) { } } +func TestHandleListSessions_TransientThoughtDoesNotInflateMessageCount(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + sessionKey := legacyPicoSessionPrefix + "history-jsonl-transient" + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + now := time.Now().UTC() + + rawJSONL := strings.Join([]string{ + `{"role":"user","content":"keep me"}`, + `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`, + `{"role":"assistant","content":"and me"}`, + }, "\n") + "\n" + if err := os.WriteFile(base+".jsonl", []byte(rawJSONL), 0o644); err != nil { + t.Fatalf("WriteFile(jsonl) error = %v", err) + } + metaData, err := json.Marshal(memory.SessionMeta{ + Key: sessionKey, + Count: 3, + Skip: 0, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("Marshal(meta) error = %v", err) + } + if err := os.WriteFile(base+".meta.json", metaData, 0o644); err != nil { + t.Fatalf("WriteFile(meta) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", 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 items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "history-jsonl-transient" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl-transient") + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2 after dropping transient thought", items[0].MessageCount) + } +} + func TestHandleListSessions_TitleUsesFirstUserMessage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -218,6 +296,136 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { } } +func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "attachment-history" + for _, msg := range []providers.Message{ + {Role: "user", Content: "send me the report"}, + { + Role: "assistant", + Content: handledToolResponseSummaryText, + Attachments: []providers.Attachment{{ + Type: "file", + Ref: "media://attachment-1", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history", 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 { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 1 { + t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" { + t.Fatalf("message = %#v, want only user request", resp.Messages[0]) + } +} + +func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "attachment-history-durable" + for _, msg := range []providers.Message{ + {Role: "user", Content: "send me the report"}, + { + Role: "assistant", + Content: handledToolResponseSummaryText, + Attachments: []providers.Attachment{{ + Type: "file", + URL: "https://example.com/report.txt", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", 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 { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + + assistant := resp.Messages[1] + if assistant.Role != "assistant" { + t.Fatalf("assistant role = %q, want assistant", assistant.Role) + } + if assistant.Content != "" { + t.Fatalf("assistant content = %q, want empty string", assistant.Content) + } + if len(assistant.Attachments) != 1 { + t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments)) + } + if assistant.Attachments[0].URL != "https://example.com/report.txt" { + t.Fatalf( + "attachment url = %q, want %q", + assistant.Attachments[0].URL, + "https://example.com/report.txt", + ) + } + if assistant.Attachments[0].Filename != "report.txt" { + t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt") + } +} + func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -293,7 +501,7 @@ func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) { } } -func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { +func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -327,10 +535,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -346,7 +551,167 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { } } -func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { +func TestHandleGetSession_ReconstructsThoughtFromAssistantReasoningContent(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-reasoning-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "final visible answer", ReasoningContent: "internal chain of thought"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-reasoning-content", 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 { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" || + resp.Messages[1].Content != "internal chain of thought" || + resp.Messages[1].Kind != "thought" { + t.Fatalf("thought message = %#v, want assistant thought/internal chain of thought", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" { + t.Fatalf("final message = %#v, want assistant/final visible answer", resp.Messages[2]) + } +} + +func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-refresh-matrix" + for _, msg := range []providers.Message{ + {Role: "user", Content: "turn1"}, + {Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"}, + {Role: "user", Content: "turn2"}, + { + Role: "assistant", + ReasoningContent: "tool thought", + ToolCalls: []providers.ToolCall{{ + ID: "call_read_file", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_read_file", Content: "file result"}, + {Role: "user", Content: "turn3"}, + { + Role: "assistant", + Content: "tool visible only", + ToolCalls: []providers.ToolCall{{ + ID: "call_list_dir", + Type: "function", + Function: &providers.FunctionCall{ + Name: "list_dir", + Arguments: `{"path":"."}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_list_dir", Content: "dir result"}, + {Role: "user", Content: "turn4"}, + { + Role: "assistant", + Content: "tool visible and thought", + ReasoningContent: "tool mixed thought", + ToolCalls: []providers.ToolCall{{ + ID: "call_exec", + Type: "function", + Function: &providers.FunctionCall{ + Name: "exec", + Arguments: `{"command":"pwd"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_exec", Content: "pwd result"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-refresh-matrix", 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 { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 13 { + t.Fatalf("len(resp.Messages) = %d, want 13", len(resp.Messages)) + } + + assertMessage := func(index int, role, kind, content string) { + t.Helper() + msg := resp.Messages[index] + if msg.Role != role || msg.Kind != kind || msg.Content != content { + t.Fatalf("messages[%d] = %#v, want role=%q kind=%q content=%q", index, msg, role, kind, content) + } + } + + assertMessage(0, "user", "", "turn1") + assertMessage(1, "assistant", "thought", "plain thought") + assertMessage(2, "assistant", "", "plain visible") + assertMessage(3, "user", "", "turn2") + assertMessage(4, "assistant", "thought", "tool thought") + assertVisibleToolCallMessage(t, resp.Messages[5], "read_file") + assertMessage(6, "user", "", "turn3") + assertMessage(7, "assistant", "", "tool visible only") + assertVisibleToolCallMessage(t, resp.Messages[8], "list_dir") + assertMessage(9, "user", "", "turn4") + assertMessage(10, "assistant", "thought", "tool mixed thought") + assertMessage(11, "assistant", "", "tool visible and thought") + assertVisibleToolCallMessage(t, resp.Messages[12], "exec") +} + +func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -394,10 +759,7 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -405,9 +767,10 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { if len(resp.Messages) != 3 { t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } + assertVisibleToolCallMessage(t, resp.Messages[1], "message") if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) } @@ -460,10 +823,7 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t * } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -471,9 +831,10 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t * if len(resp.Messages) != 4 { t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } + assertVisibleToolCallMessage(t, resp.Messages[1], "message") if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2]) } @@ -540,7 +901,68 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { } } -func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) { +func TestHandleListSessions_DeduplicatesAssistantToolCallContentFromVisibleTranscript(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "list-deduped-tool-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "Read the file before replying.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", 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 items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) + } +} + +func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -555,7 +977,7 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) {Role: "user", Content: "check file"}, { Role: "assistant", - Content: "model final reply", + Content: "Read the file before replying.", ToolCalls: []providers.ToolCall{ { ID: "call_1", @@ -564,9 +986,13 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) Name: "read_file", Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, }, }, }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { t.Fatalf("AddFullMessage() error = %v", err) @@ -586,10 +1012,74 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { + t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) + } + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.ExtraContent == nil || + toolCall.ExtraContent.ToolFeedbackExplanation != "Read the file before replying." { + t.Fatalf("tool call = %#v, want explanation", toolCall) + } +} + +func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-distinct-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "I will summarize the findings after reading the file.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-distinct-content", 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 { + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -597,15 +1087,163 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if len(resp.Messages) != 3 { t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { - t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) + if resp.Messages[1].Role != "assistant" || + resp.Messages[1].Content != "I will summarize the findings after reading the file." { + t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[1]) } - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) + assertVisibleToolCallMessage(t, resp.Messages[2], "read_file") +} + +func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" { - t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2]) + + sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-media" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check screenshot"}, + { + Role: "assistant", + Content: "Reviewing the generated screenshot.", + Media: []string{"data:image/png;base64,abc123"}, + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "view_image", + Arguments: `{"path":"artifact.png"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Reviewing the generated screenshot.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-duplicate-content-with-media", 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 { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role) + } + if resp.Messages[1].Content != "" { + t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content) + } + if len(resp.Messages[1].Media) != 1 || resp.Messages[1].Media[0] != "data:image/png;base64,abc123" { + t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[1].Media) + } + assertVisibleToolCallMessage(t, resp.Messages[2], "view_image") +} + +func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-attachments" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check report"}, + { + Role: "assistant", + Content: "Reviewing the generated report.", + Attachments: []providers.Attachment{{ + Type: "file", + URL: "https://example.com/report.txt", + Filename: "report.txt", + ContentType: "text/plain", + }}, + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"report.txt"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Reviewing the generated report.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + "/api/sessions/detail-tool-summary-duplicate-content-with-attachments", + 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 { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role) + } + if resp.Messages[1].Content != "" { + t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content) + } + if len(resp.Messages[1].Attachments) != 1 { + t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[1].Attachments)) + } + if resp.Messages[1].Attachments[0].URL != "https://example.com/report.txt" { + t.Fatalf("attachment url = %q, want report URL", resp.Messages[1].Attachments[0].URL) + } + assertVisibleToolCallMessage(t, resp.Messages[2], "read_file") } func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) { @@ -629,6 +1267,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) } argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + explanation := "Read README.md first to confirm the current project structure before editing the config example." sessionKey := picoSessionPrefix + "detail-tool-summary-max-args" err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}) if err != nil { @@ -643,6 +1282,9 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) Name: "read_file", Arguments: argsJSON, }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, }}, }) if err != nil { @@ -662,10 +1304,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } err = json.Unmarshal(rec.Body.Bytes(), &resp) if err != nil { @@ -675,12 +1314,89 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) } - wantPreview := utils.Truncate(argsJSON, 20) - if !strings.Contains(resp.Messages[1].Content, wantPreview) { - t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview) + wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{ + Function: &providers.FunctionCall{Arguments: argsJSON}, + }, 20) + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.ExtraContent == nil || toolCall.ExtraContent.ToolFeedbackExplanation != explanation { + t.Fatalf("tool call = %#v, want full explanation %q", toolCall, explanation) } - if strings.Contains(resp.Messages[1].Content, argsJSON) { - t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content) + if toolCall.Function == nil || toolCall.Function.Arguments != wantArgsPreview { + t.Fatalf("tool call = %#v, want args preview %q", toolCall, wantArgsPreview) + } +} + +func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20 + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args" + if err := store.AddFullMessage( + nil, + sessionKey, + providers.Message{Role: "user", Content: "check file"}, + ); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: argsJSON, + }, + }}, + }); err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-legacy-args", 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 { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) < 2 { + t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) + } + + wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{ + Function: &providers.FunctionCall{Arguments: argsJSON}, + }, 20) + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.Function == nil || toolCall.Function.Arguments != wantPreview { + t.Fatalf("tool call = %#v, want legacy args preview %q", toolCall, wantPreview) } } diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index 0a1bb50ee..c6c2deaae 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -261,6 +261,8 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem { status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex) case "tool_search_tool_bm25": status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25) + case "web_search": + status, reasonCode = resolveWebSearchToolSupport(cfg) case "i2c", "spi": status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) default: @@ -304,6 +306,13 @@ func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string return "enabled", "" } +func resolveWebSearchToolSupport(cfg *config.Config) (string, string) { + if !cfg.Tools.IsToolEnabled("web") { + return "disabled", "" + } + return "enabled", "" +} + func applyToolState(cfg *config.Config, toolName string, enabled bool) error { switch toolName { case "read_file": @@ -507,6 +516,7 @@ func normalizeWebSearchAPIKeys(apiKeys []string, apiKey string) ([]string, bool) } func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { + opts := picotools.WebSearchToolOptionsFromConfig(cfg) current := resolveCurrentWebSearchProvider(cfg) settings := map[string]webSearchProviderConfig{ "sogou": { @@ -563,59 +573,53 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { { ID: "sogou", Label: "Sogou", - Configured: cfg.Tools.Web.Sogou.Enabled, + Configured: picotools.WebSearchProviderReady(opts, "sogou"), Current: current == "sogou", }, { ID: "duckduckgo", Label: "DuckDuckGo", - Configured: cfg.Tools.Web.DuckDuckGo.Enabled, + Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"), Current: current == "duckduckgo", }, { - ID: "brave", - Label: "Brave Search", - Configured: cfg.Tools.Web.Brave.Enabled && - len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0, + ID: "brave", + Label: "Brave Search", + Configured: picotools.WebSearchProviderReady(opts, "brave"), Current: current == "brave", RequiresAuth: true, }, { - ID: "tavily", - Label: "Tavily", - Configured: cfg.Tools.Web.Tavily.Enabled && - len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0, + ID: "tavily", + Label: "Tavily", + Configured: picotools.WebSearchProviderReady(opts, "tavily"), Current: current == "tavily", RequiresAuth: true, }, { - ID: "perplexity", - Label: "Perplexity", - Configured: cfg.Tools.Web.Perplexity.Enabled && - len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0, + ID: "perplexity", + Label: "Perplexity", + Configured: picotools.WebSearchProviderReady(opts, "perplexity"), Current: current == "perplexity", RequiresAuth: true, }, { - ID: "searxng", - Label: "SearXNG", - Configured: cfg.Tools.Web.SearXNG.Enabled && - strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != "", - Current: current == "searxng", + ID: "searxng", + Label: "SearXNG", + Configured: picotools.WebSearchProviderReady(opts, "searxng"), + Current: current == "searxng", }, { - ID: "glm_search", - Label: "GLM Search", - Configured: cfg.Tools.Web.GLMSearch.Enabled && - cfg.Tools.Web.GLMSearch.APIKey.String() != "", + ID: "glm_search", + Label: "GLM Search", + Configured: picotools.WebSearchProviderReady(opts, "glm_search"), Current: current == "glm_search", RequiresAuth: true, }, { - ID: "baidu_search", - Label: "Baidu Search", - Configured: cfg.Tools.Web.BaiduSearch.Enabled && - cfg.Tools.Web.BaiduSearch.APIKey.String() != "", + ID: "baidu_search", + Label: "Baidu Search", + Configured: picotools.WebSearchProviderReady(opts, "baidu_search"), Current: current == "baidu_search", RequiresAuth: true, }, @@ -637,57 +641,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { } func resolveCurrentWebSearchProvider(cfg *config.Config) string { - selected := normalizeWebSearchProvider(cfg.Tools.Web.Provider) - if selected != "" && selected != "auto" && webSearchProviderConfigured(cfg, selected) { - return selected + if cfg == nil || !cfg.Tools.IsToolEnabled("web") { + return "" } - - for _, name := range []string{"perplexity", "brave", "searxng", "tavily"} { - if webSearchProviderConfigured(cfg, name) { - return name - } - } - - if webSearchProviderConfigured(cfg, "sogou") && webSearchProviderConfigured(cfg, "duckduckgo") { - if picotools.GetPreferredWebSearchLanguage() == "en" { - return "duckduckgo" - } - return "sogou" - } - if webSearchProviderConfigured(cfg, "sogou") { - return "sogou" - } - if webSearchProviderConfigured(cfg, "duckduckgo") { - return "duckduckgo" - } - - for _, name := range []string{"baidu_search", "glm_search"} { - if webSearchProviderConfigured(cfg, name) { - return name - } - } - return "" -} - -func webSearchProviderConfigured(cfg *config.Config, name string) bool { - switch name { - case "sogou": - return cfg.Tools.Web.Sogou.Enabled - case "duckduckgo": - return cfg.Tools.Web.DuckDuckGo.Enabled - case "brave": - return cfg.Tools.Web.Brave.Enabled && len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0 - case "tavily": - return cfg.Tools.Web.Tavily.Enabled && len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0 - case "perplexity": - return cfg.Tools.Web.Perplexity.Enabled && len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0 - case "searxng": - return cfg.Tools.Web.SearXNG.Enabled && strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != "" - case "glm_search": - return cfg.Tools.Web.GLMSearch.Enabled && cfg.Tools.Web.GLMSearch.APIKey.String() != "" - case "baidu_search": - return cfg.Tools.Web.BaiduSearch.Enabled && cfg.Tools.Web.BaiduSearch.APIKey.String() != "" - default: - return false + selected, err := picotools.ResolveWebSearchProviderName(picotools.WebSearchToolOptionsFromConfig(cfg), "") + if err != nil { + return "" } + return selected } diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go index 5105fc1d2..c98067e41 100644 --- a/web/backend/api/tools_test.go +++ b/web/backend/api/tools_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/sipeed/picoclaw/pkg/config" - picotools "github.com/sipeed/picoclaw/pkg/tools" ) func TestHandleListTools(t *testing.T) { @@ -198,6 +197,66 @@ func TestHandleUpdateToolState(t *testing.T) { } } +func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) { + tests := []struct { + name string + preferNative bool + }{ + {name: "without prefer_native", preferNative: false}, + {name: "with prefer_native", preferNative: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.PreferNative = tt.preferNative + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKeys(nil) + 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/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()) + } + + var resp toolSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + for _, tool := range resp.Tools { + if tool.Name != "web_search" { + continue + } + if tool.Status != "enabled" || tool.ReasonCode != "" { + t.Fatalf("web_search = %#v, want enabled with no reason code", tool) + } + return + } + + t.Fatal("expected web_search in response") + }) + } +} + func TestHandleGetWebSearchConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -206,6 +265,7 @@ func TestHandleGetWebSearchConfig(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } + cfg.Tools.Web.PreferNative = false cfg.Tools.Web.Provider = "sogou" cfg.Tools.Web.Sogou.Enabled = true cfg.Tools.Web.Sogou.MaxResults = 6 @@ -242,6 +302,48 @@ func TestHandleGetWebSearchConfig(t *testing.T) { } } +func TestHandleGetWebSearchConfig_DoesNotExposeNativeAsCurrentService(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.PreferNative = true + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKeys(nil) + 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/tools/web-search-config", 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 webSearchConfigResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if !resp.PreferNative { + t.Fatal("prefer_native should remain true in response") + } + if resp.CurrentService != "" { + t.Fatalf("current_service = %q, want empty when no external provider is ready", resp.CurrentService) + } +} + func TestHandleUpdateWebSearchConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -393,23 +495,53 @@ func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t } } -func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) { +func TestResolveCurrentWebSearchProvider_FallsBackWhenExplicitProviderUnavailable(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) + } +} + +func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "totally_unknown" + cfg.Tools.Web.Sogou.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) + } +} + +func TestResolveCurrentWebSearchProvider_PrefersStableDefaultForSogouAndDuckDuckGo(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Web.Provider = "auto" cfg.Tools.Web.Sogou.Enabled = true cfg.Tools.Web.DuckDuckGo.Enabled = true - picotools.SetPreferredWebSearchLanguage("en") - t.Cleanup(func() { - picotools.SetPreferredWebSearchLanguage("") - }) - - if got := resolveCurrentWebSearchProvider(cfg); got != "duckduckgo" { - t.Fatalf("resolveCurrentWebSearchProvider() = %q, want duckduckgo", got) - } - - picotools.SetPreferredWebSearchLanguage("zh") if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) } } + +func TestResolveCurrentWebSearchProvider_IgnoresPreferNativeInConfigView(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "custom-default", + Model: "openai/gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-default"), + }} + cfg.Agents.Defaults.ModelName = "custom-default" + cfg.Tools.Web.PreferNative = true + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want empty when only native search would be available", got) + } +} diff --git a/web/backend/api/ui.go b/web/backend/api/ui.go deleted file mode 100644 index 90d96403e..000000000 --- a/web/backend/api/ui.go +++ /dev/null @@ -1,27 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/sipeed/picoclaw/pkg/tools" -) - -type uiLanguageRequest struct { - Language string `json:"language"` -} - -func (h *Handler) registerUIRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /api/ui/language", h.handleSetUILanguage) -} - -func (h *Handler) handleSetUILanguage(w http.ResponseWriter, r *http.Request) { - var req uiLanguageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - - tools.SetPreferredWebSearchLanguage(req.Language) - w.WriteHeader(http.StatusNoContent) -} diff --git a/web/backend/api/ui_test.go b/web/backend/api/ui_test.go deleted file mode 100644 index 3de35b7cb..000000000 --- a/web/backend/api/ui_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/sipeed/picoclaw/pkg/tools" -) - -func TestHandleSetUILanguage(t *testing.T) { - tools.SetPreferredWebSearchLanguage("") - t.Cleanup(func() { - tools.SetPreferredWebSearchLanguage("") - }) - - h := NewHandler("") - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{"language":"zh"}`)) - req.Header.Set("Content-Type", "application/json") - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - if got := tools.GetPreferredWebSearchLanguage(); got != "zh" { - t.Fatalf("preferred web search language = %q, want zh", got) - } -} - -func TestHandleSetUILanguage_RejectsInvalidJSON(t *testing.T) { - h := NewHandler("") - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{`)) - req.Header.Set("Content-Type", "application/json") - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) - } -} diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index b6faa63fe..e3595738f 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -1,8 +1,6 @@ package launcherconfig import ( - "crypto/rand" - "encoding/base64" "encoding/json" "fmt" "net" @@ -16,31 +14,19 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 - // EnvLauncherToken overrides launcher dashboard token. - EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN" // EnvLauncherHost overrides launcher listen host. EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST" - - // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits). - dashboardSigningKeyBytes = 32 - // dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits). - dashboardTokenEntropyBytes = 32 -) - -type DashboardTokenSource string - -const ( - DashboardTokenSourceEnv DashboardTokenSource = "env" - DashboardTokenSourceConfig DashboardTokenSource = "config" - DashboardTokenSourceRandom DashboardTokenSource = "random" ) // Config stores launch parameters for the web backend service. type Config struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` - LauncherToken string `json:"launcher_token,omitempty"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"` + // LegacyLauncherToken is read only for one-time migration from the removed + // token login flow. Save always clears it so new configs do not persist it. + LegacyLauncherToken string `json:"launcher_token,omitempty"` } // Default returns default launcher settings. @@ -61,41 +47,6 @@ func Validate(cfg Config) error { return nil } -// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this -// process. The signing key is freshly random each call; the token comes from -// EnvLauncherToken when set, otherwise launcher-config.json launcher_token, -// otherwise a new random token. -func EnsureDashboardSecrets( - cfg Config, -) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) { - signingKey = make([]byte, dashboardSigningKeyBytes) - if _, err = rand.Read(signingKey); err != nil { - return "", nil, "", err - } - - effectiveToken = strings.TrimSpace(os.Getenv(EnvLauncherToken)) - if effectiveToken != "" { - return effectiveToken, signingKey, DashboardTokenSourceEnv, nil - } - effectiveToken = strings.TrimSpace(cfg.LauncherToken) - if effectiveToken != "" { - return effectiveToken, signingKey, DashboardTokenSourceConfig, nil - } - tok, genErr := randomDashboardToken() - if genErr != nil { - return "", nil, "", genErr - } - return tok, signingKey, DashboardTokenSourceRandom, nil -} - -func randomDashboardToken() (string, error) { - buf := make([]byte, dashboardTokenEntropyBytes) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buf), nil -} - // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. func NormalizeCIDRs(cidrs []string) []string { if len(cidrs) == 0 { @@ -144,7 +95,8 @@ func Load(path string, fallback Config) (Config, error) { return Config{}, err } cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) - cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken) if err := Validate(cfg); err != nil { return Config{}, err } @@ -154,7 +106,8 @@ func Load(path string, fallback Config) (Config, error) { // Save writes launcher settings to disk. func Save(path string, cfg Config) error { cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) - cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = "" if err := Validate(cfg); err != nil { return err } diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index 528116417..bb13ea115 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -1,11 +1,10 @@ package launcherconfig import ( + "context" "os" "path/filepath" "testing" - - "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestLoadReturnsFallbackWhenMissing(t *testing.T) { @@ -25,10 +24,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "launcher-config.json") want := Config{ - Port: 18080, - Public: true, - AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, - LauncherToken: "saved-launcher-token", + Port: 18080, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash", + LegacyLauncherToken: "legacy-token-should-not-persist", } if err := Save(path, want); err != nil { @@ -41,8 +41,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { if got.Port != want.Port || got.Public != want.Public { t.Fatalf("Load() = %+v, want %+v", got, want) } - if got.LauncherToken != want.LauncherToken { - t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken) + if got.DashboardPasswordHash != want.DashboardPasswordHash { + t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash) + } + if got.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken) } if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) { t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs)) @@ -62,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { } } +func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + got, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.LegacyLauncherToken != "legacy-token" { + t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken) + } +} + func TestValidateRejectsInvalidPort(t *testing.T) { if err := Validate(Config{Port: 0, Public: false}); err == nil { t.Fatal("Validate() expected error for port 0") @@ -81,66 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } -func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - - tok, key, source, err := EnsureDashboardSecrets(Default()) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes { - t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key)) - } - mac := middleware.SessionCookieValue(key, tok) - if mac == "" { - t.Fatal("empty session mac") - } - - tok2, key2, source2, err := EnsureDashboardSecrets(Default()) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() second error = %v", err) - } - if source2 != DashboardTokenSourceRandom { - t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom) - } - if tok2 == tok { - t.Fatal("expected a new random dashboard token") - } - if string(key2) == string(key) { - t.Fatal("expected a new signing key") - } -} - -func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override") - - tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if tok != "env-only-token-override" { - t.Fatalf("token = %q, want env value", tok) - } - if source != DashboardTokenSourceEnv { - t.Fatalf("source = %q, want %q", source, DashboardTokenSourceEnv) - } -} - -func TestEnsureDashboardSecrets_ConfigOverridesGenerated(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - - tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if tok != "config-token" { - t.Fatalf("token = %q, want config value", tok) - } - if source != DashboardTokenSourceConfig { - t.Fatalf("source = %q, want %q", source, DashboardTokenSourceConfig) - } -} - func TestNormalizeCIDRs(t *testing.T) { got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) want := []string{"192.168.1.0/24", "10.0.0.0/8"} @@ -153,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) { } } } + +func TestPasswordStoreSetAndVerify(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + store := NewPasswordStore(path, Default()) + ctx := context.Background() + + initialized, err := store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() error = %v", err) + } + if initialized { + t.Fatal("IsInitialized() = true, want false before SetPassword") + } + + if err = store.SetPassword(ctx, "dashboard-password"); err != nil { + t.Fatalf("SetPassword() error = %v", err) + } + initialized, err = store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() after SetPassword error = %v", err) + } + if !initialized { + t.Fatal("IsInitialized() = false, want true after SetPassword") + } + ok, err := store.VerifyPassword(ctx, "dashboard-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword(correct) = false, want true") + } + ok, err = store.VerifyPassword(ctx, "wrong-password") + if err != nil { + t.Fatalf("VerifyPassword(wrong) error = %v", err) + } + if ok { + t.Fatal("VerifyPassword(wrong) = true, want false") + } +} diff --git a/web/backend/launcherconfig/migration.go b/web/backend/launcherconfig/migration.go new file mode 100644 index 000000000..66caa73ae --- /dev/null +++ b/web/backend/launcherconfig/migration.go @@ -0,0 +1,62 @@ +package launcherconfig + +import ( + "context" + "strings" +) + +var ( + loadConfigForMigration = Load + saveConfigForMigration = Save +) + +type dashboardPasswordStore interface { + IsInitialized(ctx context.Context) (bool, error) + SetPassword(ctx context.Context, plain string) error +} + +// LegacyLauncherTokenMigrationResult reports the outcome of converting a +// removed launcher_token value into the current password-based auth flow. +type LegacyLauncherTokenMigrationResult struct { + Migrated bool + // CleanupErr is non-nil when password migration succeeded (or was already in + // place) but removing launcher_token from launcher-config.json failed. + CleanupErr error +} + +// MigrateLegacyLauncherToken converts the removed launcher_token setting into +// the current password-login store, then removes launcher_token from config. +func MigrateLegacyLauncherToken( + ctx context.Context, + store dashboardPasswordStore, + launcherPath string, + fallback Config, +) (LegacyLauncherTokenMigrationResult, error) { + legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken) + if legacyToken == "" || store == nil { + return LegacyLauncherTokenMigrationResult{}, nil + } + + result := LegacyLauncherTokenMigrationResult{} + initialized, err := store.IsInitialized(ctx) + if err != nil { + return result, err + } + if !initialized { + if err = store.SetPassword(ctx, legacyToken); err != nil { + return result, err + } + result.Migrated = true + } + result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback) + return result, nil +} + +func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error { + cfg, err := loadConfigForMigration(launcherPath, fallback) + if err != nil { + return err + } + cfg.LegacyLauncherToken = "" + return saveConfigForMigration(launcherPath, cfg) +} diff --git a/web/backend/launcherconfig/migration_test.go b/web/backend/launcherconfig/migration_test.go new file mode 100644 index 000000000..c5c5fa2c9 --- /dev/null +++ b/web/backend/launcherconfig/migration_test.go @@ -0,0 +1,135 @@ +package launcherconfig + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +type stubMigrationPasswordStore struct { + initialized bool + password string +} + +func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) { + return s.initialized, nil +} + +func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error { + s.password = plain + s.initialized = true + return nil +} + +func TestMigrateLegacyLauncherToken(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken) + } + if loaded.DashboardPasswordHash == "" { + t.Fatal("dashboard password hash should be set after migration") + } + ok, err := store.VerifyPassword(context.Background(), "legacy-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword() = false, want true") + } +} + +func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := &stubMigrationPasswordStore{} + origSave := saveConfigForMigration + saveConfigForMigration = func(string, Config) error { + return errors.New("write launcher config") + } + t.Cleanup(func() { + saveConfigForMigration = origSave + }) + + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr == nil { + t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil") + } + if store.password != "legacy-password" { + t.Fatalf("password = %q, want legacy-password", store.password) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "legacy-password" { + t.Fatalf( + "legacy launcher token = %q, want legacy-password after cleanup failure", + loaded.LegacyLauncherToken, + ) + } +} + +func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) { + launcherPath := filepath.Join(t.TempDir(), FileName) + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default()) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } +} diff --git a/web/backend/launcherconfig/password_store.go b/web/backend/launcherconfig/password_store.go new file mode 100644 index 000000000..3813384bb --- /dev/null +++ b/web/backend/launcherconfig/password_store.go @@ -0,0 +1,92 @@ +package launcherconfig + +import ( + "context" + "errors" + "strings" + "sync" + + "golang.org/x/crypto/bcrypt" +) + +const passwordBcryptCost = 12 + +// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json. +// It is used on platforms where the SQLite-backed dashboard auth store is not +// available. +type PasswordStore struct { + path string + fallback Config + mu sync.Mutex +} + +// NewPasswordStore returns a config-backed password store. +func NewPasswordStore(path string, fallback Config) *PasswordStore { + return &PasswordStore{ + path: path, + fallback: fallback, + } +} + +// IsInitialized reports whether a dashboard password hash exists in config. +func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil +} + +// SetPassword hashes plain with bcrypt and writes it to launcher-config.json. +func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error { + if err := ctx.Err(); err != nil { + return err + } + if len([]rune(plain)) == 0 { + return errors.New("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + cfg, err := Load(s.path, s.fallback) + if err != nil { + return err + } + cfg.DashboardPasswordHash = string(hash) + cfg.LegacyLauncherToken = "" + return Save(s.path, cfg) +} + +// VerifyPassword returns true iff plain matches the stored bcrypt hash. +func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + hash := strings.TrimSpace(cfg.DashboardPasswordHash) + if hash == "" { + return false, nil + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return err == nil, err +} + +func (s *PasswordStore) load() (Config, error) { + s.mu.Lock() + defer s.mu.Unlock() + return Load(s.path, s.fallback) +} diff --git a/web/backend/main.go b/web/backend/main.go index 01ef5edf0..fa2448d5c 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -12,12 +12,12 @@ package main import ( + "context" "errors" "flag" "fmt" "net" "net/http" - "net/url" "os" "os/signal" "path/filepath" @@ -29,7 +29,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/netbind" - "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/dashboardauth" "github.com/sipeed/picoclaw/web/backend/launcherconfig" @@ -51,7 +50,6 @@ var ( servers []*http.Server serverAddr string // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). - // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use. browserLaunchURL string apiHandler *api.Handler @@ -62,11 +60,34 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { return !enableConsole || debug } -func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string { - if source != launcherconfig.DashboardTokenSourceConfig { - return "" +func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool { + return !noBrowser && isLoopbackLaunchHost(probeHost) +} + +func isLoopbackLaunchHost(host string) bool { + host = strings.TrimSpace(host) + if strings.EqualFold(host, "localhost") { + return true } - return launcherPath + host = strings.Trim(host, "[]") + if i := strings.LastIndex(host, "%"); i >= 0 { + host = host[:i] + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func launcherBrowserLaunchSuffix( + needsSetup bool, + localAutoLogin *middleware.LauncherDashboardLocalAutoLogin, +) string { + if needsSetup { + return middleware.LauncherDashboardSetupPath + } + if localAutoLogin != nil { + return localAutoLogin.URLPath() + } + return "" } func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) { @@ -318,24 +339,6 @@ func firstNonEmpty(values ...string) string { return "" } -// maskSecret masks a secret for display. It always shows up to the first 3 -// runes. The last 4 runes are only appended when at least 5 runes remain -// hidden in the middle (i.e. string length >= 12), so an 8-char minimum -// password never exposes its tail. Strings of 3 chars or fewer are fully -// masked. -func maskSecret(s string) string { - runes := []rune(s) - n := len(runes) - const prefixLen, suffixLen, minHidden = 3, 4, 5 - if n < prefixLen+suffixLen+minHidden { - if n <= prefixLen { - return "**********" - } - return string(runes[:prefixLen]) + "**********" - } - return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:]) -} - func main() { port := flag.String("port", "18800", "Port to listen on") host := flag.String("host", "", "Host to listen on (overrides -public when set)") @@ -405,7 +408,6 @@ func main() { if *lang != "" { SetLanguage(*lang) } - tools.SetPreferredWebSearchLanguage(string(GetLanguage())) // Resolve config path configPath := utils.GetDefaultConfigPath() @@ -503,15 +505,11 @@ func main() { } listeners := openResult.Listeners - dashboardToken, dashboardSigningKey, _, dashErr := launcherconfig.EnsureDashboardSecrets( - launcherCfg, - ) + dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie() if dashErr != nil { logger.Fatalf("Dashboard auth setup failed: %v", dashErr) } - dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) - fmt.Println("dashboardToken: ", dashboardToken) // Open the bcrypt password store (creates the DB file on first run). authStore, authStoreErr := dashboardauth.New(picoHome) var passwordStore api.PasswordStore @@ -522,29 +520,68 @@ func main() { logger.InfoC( "web", fmt.Sprintf( - "Dashboard password store unavailable on this platform; falling back to token login: %v", + "Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v", authStoreErr, ), ) + passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg) authStoreErr = nil } else { logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) } + migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken( + context.Background(), + passwordStore, + launcherPath, + launcherCfg, + ) + if migrationErr != nil { + logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr) + } + if migrationResult.Migrated { + logger.InfoC("web", "Migrated legacy launcher token to dashboard password login") + } + if migrationResult.CleanupErr != nil { + logger.WarnC( + "web", + fmt.Sprintf( + "Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v", + launcherPath, + migrationResult.CleanupErr, + ), + ) + } + + var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin + needsInitialSetup := false + if passwordStore != nil { + initialized, initErr := passwordStore.IsInitialized(context.Background()) + if initErr != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr)) + } else if !initialized { + needsInitialSetup = true + } else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) { + localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute) + if err != nil { + logger.Fatalf("Failed to create local auto-login grant: %v", err) + } + } + } + // Initialize Server components mux := http.NewServeMux() api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ - DashboardToken: dashboardToken, - SessionCookie: dashboardSessionCookie, - PasswordStore: passwordStore, - StoreError: authStoreErr, + SessionCookie: dashboardSessionCookie, + PasswordStore: passwordStore, + StoreError: authStoreErr, }) // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) apiHandler.SetDebug(debug) - if _, err = apiHandler.EnsurePicoChannel(""); err != nil { + if _, err = apiHandler.EnsurePicoChannel(); err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) @@ -561,7 +598,7 @@ func main() { dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ ExpectedCookie: dashboardSessionCookie, - Token: dashboardToken, + LocalAutoLogin: localAutoLogin, }, accessControlledMux) // Apply middleware stack @@ -573,13 +610,21 @@ func main() { ), ) - // Print startup banner and token (console mode only). + // Print startup banner (console mode only). if enableConsole || debug { consoleHosts := launcherConsoleHosts(hostInput, effectivePublic) fmt.Print(utils.Banner) fmt.Println() - fmt.Println(" Open the following URL in your browser:") + if needsInitialSetup { + if *noBrowser { + fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.") + } else { + fmt.Println(" Launcher will open /launcher-setup automatically.") + } + fmt.Println() + } + fmt.Println(" Dashboard address:") fmt.Println() for _, host := range consoleHosts { fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) @@ -599,11 +644,7 @@ func main() { // Share the local URL with the launcher runtime. serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort)) - if dashboardToken != "" { - browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) - } else { - browserLaunchURL = serverAddr - } + browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin) // Auto-open browser will be handled by the launcher runtime. diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 6df5370b1..aea02927e 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/netbind" - "github.com/sipeed/picoclaw/web/backend/launcherconfig" + "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestShouldEnableLauncherFileLogging(t *testing.T) { @@ -43,60 +43,50 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) { } } -func TestDashboardTokenConfigHelpPath(t *testing.T) { - const launcherPath = "/tmp/launcher-config.json" - +func TestShouldEnableLocalAutoLogin(t *testing.T) { tests := []struct { - name string - source launcherconfig.DashboardTokenSource - want string + name string + noBrowser bool + probeHost string + wantEnable bool }{ - { - name: "env token does not expose config path", - source: launcherconfig.DashboardTokenSourceEnv, - want: "", - }, - { - name: "config token exposes config path", - source: launcherconfig.DashboardTokenSourceConfig, - want: launcherPath, - }, - { - name: "random token does not expose config path", - source: launcherconfig.DashboardTokenSourceRandom, - want: "", - }, + {name: "loopback localhost", probeHost: "localhost", wantEnable: true}, + {name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true}, + {name: "loopback ipv6", probeHost: "::1", wantEnable: true}, + {name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false}, + {name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false}, + {name: "non-loopback hostname", probeHost: "example.com", wantEnable: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want { - t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want) + if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable { + t.Fatalf( + "shouldEnableLocalAutoLogin(%t, %q) = %t, want %t", + tt.noBrowser, + tt.probeHost, + got, + tt.wantEnable, + ) } }) } } -func TestMaskSecret(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"sdhjflsjdflksdf", "sdh**********ksdf"}, - {"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"}, - {"abcdefghijkl", "abc**********ijkl"}, - {"abcdefgh", "abc**********"}, - {"abcdefghijk", "abc**********"}, - {"abcdefg", "abc**********"}, - {"abcd", "abc**********"}, - {"abc", "**********"}, - {"", "**********"}, +func TestLauncherBrowserLaunchSuffix(t *testing.T) { + autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) } - for _, tt := range tests { - if got := maskSecret(tt.input); got != tt.want { - t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want) - } + if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath { + t.Fatalf("setup suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") { + t.Fatalf("auto-login suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, nil); got != "" { + t.Fatalf("empty suffix = %q, want empty", got) } } diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index c1c4c19c6..fd59958a9 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -1,41 +1,88 @@ package middleware import ( - "crypto/hmac" - "crypto/sha256" + "crypto/rand" "crypto/subtle" - "encoding/hex" + "encoding/base64" + "errors" "net/http" + "net/url" "path" "strings" + "sync" "time" ) -// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login. +// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login. const LauncherDashboardCookieName = "picoclaw_launcher_auth" -// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days). -const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600 +// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days). +const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600 -const launcherSessionMACLabel = "picoclaw-launcher-v1" +const ( + launcherSessionCookieBytes = 32 + launcherGrantNonceBytes = 32 + // LauncherDashboardLocalAutoLoginPath is the one-shot local browser + // bootstrap endpoint used by the launcher-managed auto-open flow. + LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login" + // LauncherDashboardSetupPath is the setup page used before the dashboard + // password is initialized. + LauncherDashboardSetupPath = "/launcher-setup" +) -// SessionCookieValue is the expected cookie value for the given signing key and dashboard token. -func SessionCookieValue(signingKey []byte, dashboardToken string) string { - mac := hmac.New(sha256.New, signingKey) - _, _ = mac.Write([]byte(launcherSessionMACLabel)) - _, _ = mac.Write([]byte{0}) - _, _ = mac.Write([]byte(dashboardToken)) - return hex.EncodeToString(mac.Sum(nil)) +// NewLauncherDashboardSessionCookie creates the per-process session cookie value. +func NewLauncherDashboardSessionCookie() (string, error) { + return randomURLToken(launcherSessionCookieBytes) +} + +func randomURLToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil } // LauncherDashboardAuthConfig holds runtime material for dashboard access checks. type LauncherDashboardAuthConfig struct { ExpectedCookie string - Token string + // LocalAutoLogin enables one-shot startup auto-login. + LocalAutoLogin *LauncherDashboardLocalAutoLogin // SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used. SecureCookie func(*http.Request) bool } +// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant. +// It is not a reusable credential; it only lets the launcher-opened browser +// receive the current process session cookie. +type LauncherDashboardLocalAutoLogin struct { + grant *launcherDashboardOneTimeGrant +} + +type launcherDashboardOneTimeGrant struct { + mu sync.Mutex + expires time.Time + consumed bool + nonce string + now func() time.Time +} + +// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant. +func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) { + grant, err := newLauncherDashboardOneTimeGrant(ttl) + if err != nil { + return nil, err + } + return &LauncherDashboardLocalAutoLogin{ + grant: grant, + }, nil +} + +// URLPath returns the one-shot local auto-login URL path including its nonce. +func (a *LauncherDashboardLocalAutoLogin) URLPath() string { + return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant) +} + // DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto). func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { if r.TLS != nil { @@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } -// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login. +// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login. func SetLauncherDashboardSessionCookie( w http.ResponseWriter, r *http.Request, @@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, }) } -// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer -// before calling next. Public paths are login page and /api/auth/* handlers. +// LauncherDashboardAuth requires a valid session cookie before calling next. +// Public paths are login/setup pages and /api/auth/* handlers. func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := canonicalAuthPath(r.URL.Path) - if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled { + if p == LauncherDashboardLocalAutoLoginPath { + handleLauncherLocalAutoLogin(w, r, cfg) return } if isPublicLauncherDashboardPath(r.Method, p) { @@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h // canonicalAuthPath matches path cleaning used for routing decisions so // prefixes like /assets/../ cannot bypass auth (CVE-class traversal). -// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session -// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects. -// Invalid token is rejected like any other unauthenticated browser request. -func tryLauncherQueryTokenLogin( - w http.ResponseWriter, - r *http.Request, - canonicalPath string, - cfg LauncherDashboardAuthConfig, -) bool { - if r.Method != http.MethodGet { - return false +func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) { + if validLauncherDashboardAuth(r, cfg) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return } - if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") { - return false + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte("method not allowed")) + return } - qToken := strings.TrimSpace(r.URL.Query().Get("token")) - if qToken == "" { - return false + if r.Method == http.MethodHead { + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) + return } - if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 { - rejectLauncherDashboardAuth(w, r, canonicalPath) - return true + if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) { + SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) + http.Redirect(w, r, "/", http.StatusSeeOther) + return } - SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) - http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther) - return true + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) } -func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string { - if canonicalPath == "/launcher-login" { - return "/" +func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool { + if a == nil || a.grant == nil { + return false } - q := r.URL.Query() - q.Del("token") - enc := q.Encode() - if enc != "" { - return canonicalPath + "?" + enc + return a.grant.use(nonce, nil) == nil +} + +func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) { + nonce, err := randomURLToken(launcherGrantNonceBytes) + if err != nil { + return nil, err } - return canonicalPath + return &launcherDashboardOneTimeGrant{ + expires: time.Now().Add(ttl), + nonce: nonce, + now: time.Now, + }, nil +} + +func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string { + if grant == nil { + return basePath + } + return basePath + "?nonce=" + url.QueryEscape(grant.nonce) +} + +// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing, +// expired, already consumed, or otherwise invalid. +var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant") + +func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error { + if g == nil { + return ErrInvalidLauncherDashboardGrant + } + if len(nonce) != len(g.nonce) || + subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 { + return ErrInvalidLauncherDashboardGrant + } + + g.mu.Lock() + defer g.mu.Unlock() + + now := time.Now + if g.now != nil { + now = g.now + } + if g.consumed || !now().Before(g.expires) { + return ErrInvalidLauncherDashboardGrant + } + if fn != nil { + if err := fn(); err != nil { + return err + } + } + g.consumed = true + return nil } func canonicalAuthPath(raw string) string { @@ -206,18 +293,14 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig return true } } - auth := r.Header.Get("Authorization") - const prefix = "Bearer " - if strings.HasPrefix(auth, prefix) { - token := strings.TrimSpace(auth[len(prefix):]) - if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 { - return true - } - } return false } func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) { + if canonicalPath == "/pico/ws" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } if strings.HasPrefix(canonicalPath, "/api/") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go index 1b919bf96..871b6f607 100644 --- a/web/backend/middleware/launcher_dashboard_auth_test.go +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -4,26 +4,37 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) -func TestSessionCookieValue_Deterministic(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = byte(i) +func TestNewLauncherDashboardSessionCookie(t *testing.T) { + a, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err) } - a := SessionCookieValue(key, "tok-a") - b := SessionCookieValue(key, "tok-a") - if a != b || a == "" { - t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b) + b, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err) } - c := SessionCookieValue(key, "tok-b") - if c == a { - t.Fatal("SessionCookieValue should differ for different tokens") + if a == "" || b == "" { + t.Fatalf("session cookie values should be non-empty: %q %q", a, b) + } + if a == b { + t.Fatal("session cookie values should be random") } } +func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin { + t.Helper() + autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) + } + return autoLogin +} + func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) @@ -34,12 +45,15 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { want int }{ {http.MethodGet, "/launcher-login", http.StatusTeapot}, + {http.MethodGet, "/launcher-setup", http.StatusTeapot}, {http.MethodGet, "/assets/index.js", http.StatusTeapot}, {http.MethodPost, "/api/auth/login", http.StatusTeapot}, {http.MethodGet, "/api/auth/status", http.StatusTeapot}, + {http.MethodPost, "/api/auth/setup", http.StatusTeapot}, {http.MethodPost, "/api/auth/logout", http.StatusTeapot}, {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, {http.MethodGet, "/api/config", http.StatusUnauthorized}, + {http.MethodGet, "/pico/ws", http.StatusUnauthorized}, } { rec := httptest.NewRecorder() req := httptest.NewRequest(tc.method, tc.path, nil) @@ -50,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { } } -func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) { - const tok = "secret" - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok} +func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusTeapot) + t.Fatal("next handler should not run without session cookie") }) h := LauncherDashboardAuth(cfg, next) rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil) + req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil) h.ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location")) } - if got := rec.Header().Get("Location"); got != "/" { - t.Fatalf("Location = %q, want %q", got, "/") +} + +func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) { + const cookieVal = "session-cookie-value" + autoLogin := mustLocalAutoLogin(t, time.Minute) + cfg := LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, } - if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName { - t.Fatalf("expected one session cookie, got %#v", c) + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login without nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - rec1b := httptest.NewRecorder() - req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil) - h.ServeHTTP(rec1b, req1b) - if rec1b.Code != http.StatusSeeOther { - t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code) - } - if got := rec1b.Header().Get("Location"); got != "/config?keep=1" { - t.Fatalf("Location = %q, want /config?keep=1", got) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login with wrong nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - recBad := httptest.NewRecorder() - reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil) - h.ServeHTTP(recBad, reqBad) - if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" { - t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login HEAD code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil) - h.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusUnauthorized { - t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal { + t.Fatalf("cookies = %#v", cookies) + } + if cookies[0].MaxAge != 31*24*3600 { + t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge) } - rec3 := httptest.NewRecorder() - req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil) - h.ServeHTTP(rec3, req3) - if rec3.Code != http.StatusFound { - t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie auth after auto-login status = %d", rec.Code) } - recLogin := httptest.NewRecorder() - reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil) - h.ServeHTTP(recLogin, reqLogin) - if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" { - t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) { + const cookieVal = "session-cookie-value" + newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler { + return LauncherDashboardAuth(LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, + }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + } + + autoLogin := mustLocalAutoLogin(t, time.Minute) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.RemoteAddr = "192.168.1.50:12345" + req.Host = "192.168.1.50:18800" + newHandler(autoLogin).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 { + t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) + } + + expired := mustLocalAutoLogin(t, -time.Second) + h := newHandler(expired) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 { + t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) } } func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("next handler should not run without auth") }) @@ -131,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { } } -func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = 0xab - } - token := "dashboard-secret-9" - cookieVal := SessionCookieValue(key, token) - cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token} +func TestLauncherDashboardAuth_CookieOnly(t *testing.T) { + cookieVal := "session-cookie-value" + cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -153,10 +237,29 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { } rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/", nil) - req2.Header.Set("Authorization", "Bearer "+token) + req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req2.Header.Set("Authorization", "Bearer dashboard-secret-9") h.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusOK { - t.Fatalf("bearer auth: status = %d", rec2.Code) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code) + } +} + +func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without auth") + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) } } diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go index 5ac066614..6cb14669d 100644 --- a/web/backend/middleware/referrer_policy.go +++ b/web/backend/middleware/referrer_policy.go @@ -2,8 +2,8 @@ package middleware import "net/http" -// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive -// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header. +// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response +// so sensitive paths and query parameters are not leaked via the Referer header. func ReferrerPolicyNoReferrer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Referrer-Policy", "no-referrer") diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js index 85d380c4f..884649e41 100644 --- a/web/frontend/eslint.config.js +++ b/web/frontend/eslint.config.js @@ -22,6 +22,7 @@ export default defineConfig([ globals: globals.browser, }, rules: { + "react-hooks/set-state-in-effect": "off", "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, diff --git a/web/frontend/package.json b/web/frontend/package.json index 7595c46bf..ab07b40a2 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -20,27 +20,27 @@ "@fontsource-variable/inter": "^5.2.8", "@tabler/icons-react": "^3.40.0", "@tailwindcss/vite": "^4.2.2", - "@tanstack/react-query": "^5.97.0", - "@tanstack/react-router": "^1.167.0", - "@tanstack/react-router-devtools": "^1.163.3", + "@tanstack/react-query": "^5.99.0", + "@tanstack/react-router": "^1.168.23", + "@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.3", + "i18next": "^26.0.7", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.19.1", "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", - "react-i18next": "^17.0.2", + "react-i18next": "^17.0.4", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", - "shadcn": "^4.2.0", + "shadcn": "^4.3.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", @@ -52,20 +52,20 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/node": "^25.5.0", + "@types/node": "^25.6.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/eslint-plugin": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^10.1.0", + "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "prettier": "^3.8.1", + "globals": "^17.5.0", + "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.57.1", - "vite": "^8.0.8" + "typescript-eslint": "^8.59.0", + "vite": "^8.0.10" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 721bd7e75..cb5ca18de 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -16,16 +16,16 @@ importers: version: 3.41.1(react@19.2.5) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + 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)) '@tanstack/react-query': - specifier: ^5.97.0 - version: 5.97.0(react@19.2.5) + specifier: ^5.99.0 + version: 5.99.0(react@19.2.5) '@tanstack/react-router': - specifier: ^1.167.0 - version: 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.168.23 + version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router-devtools': - specifier: ^1.163.3 - version: 1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + 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) 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.3 - version: 26.0.3(typescript@5.9.3) + specifier: ^26.0.7 + version: 26.0.7(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -57,8 +57,8 @@ importers: specifier: 19.2.5 version: 19.2.5(react@19.2.5) react-i18next: - specifier: ^17.0.2 - version: 17.0.2(i18next@26.0.3(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + 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) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) @@ -78,8 +78,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 shadcn: - specifier: ^4.2.0 - version: 4.2.0(@types/node@25.5.0)(typescript@5.9.3) + specifier: ^4.3.0 + version: 4.3.0(@types/node@25.6.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -98,19 +98,19 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.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)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 - version: 6.0.2(prettier@3.8.1) + version: 6.0.2(prettier@3.8.3) '@types/node': - specifier: ^25.5.0 - version: 25.5.0 + specifier: ^25.6.0 + version: 25.6.0 '@types/react': specifier: ^19.2.7 version: 19.2.14 @@ -118,41 +118,41 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': - specifier: ^8.57.1 - version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + 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) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8(@types/node@25.5.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.6.1)(tsx@4.21.0)) eslint: - specifier: ^10.1.0 - version: 10.1.0(jiti@2.6.1) + specifier: ^10.2.1 + version: 10.2.1(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) + version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@10.1.0(jiti@2.6.1)) + specifier: ^7.1.1 + version: 7.1.1(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-react-refresh: specifier: ^0.5.2 - version: 0.5.2(eslint@10.1.0(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.1(jiti@2.6.1)) globals: - specifier: ^17.4.0 - version: 17.4.0 + specifier: ^17.5.0 + version: 17.5.0 prettier: - specifier: ^3.8.1 - version: 3.8.1 + specifier: ^3.8.3 + version: 3.8.3 prettier-plugin-tailwindcss: specifier: ^0.7.2 - version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1) + version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3) typescript: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.57.1 - version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.59.0 + version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^8.0.8 - version: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + 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) packages: @@ -299,11 +299,11 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@emnapi/core@1.9.2': - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -474,16 +474,16 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.23.3': - resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.5.3': - resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@1.1.1': - resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@10.0.1': @@ -495,12 +495,12 @@ packages: eslint: optional: true - '@eslint/object-schema@3.0.3': - resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.6.1': - resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@floating-ui/core@1.7.5': @@ -521,8 +521,8 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} - '@hono/node-server@1.19.13': - resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -543,35 +543,35 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + '@inquirer/confirm@6.0.11': + resolution: {integrity: sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + '@inquirer/core@11.1.8': + resolution: {integrity: sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -608,8 +608,8 @@ packages: resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} - '@napi-rs/wasm-runtime@1.1.3': - resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -641,14 +641,17 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + '@open-draft/logger@0.3.0': resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-project/types@0.124.0': - resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1340,97 +1343,103 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@rolldown/binding-android-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': - resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} - engines: {node: '>=14.0.0'} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.15': - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -1488,24 +1497,28 @@ packages: 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==} 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==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1549,28 +1562,28 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.97.0': - resolution: {integrity: sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==} + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} - '@tanstack/react-query@5.97.0': - resolution: {integrity: sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==} + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.166.11': - resolution: {integrity: sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg==} + '@tanstack/react-router-devtools@1.166.13': + resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.168.2 - '@tanstack/router-core': ^1.168.2 + '@tanstack/react-router': ^1.168.15 + '@tanstack/router-core': ^1.168.11 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.168.8': - resolution: {integrity: sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw==} + '@tanstack/react-router@1.168.23': + resolution: {integrity: sha512-+GblieDnutG6oipJJPNtRJjrWF8QTZEG/l0532+BngFkVK48oHNOcvIkSoAFYftK1egAwM7KBxXsb0Ou+X6/MQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1582,16 +1595,21 @@ 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-devtools-core@1.167.1': - resolution: {integrity: sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w==} + '@tanstack/router-devtools-core@1.167.3': + resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.168.2 + '@tanstack/router-core': ^1.168.11 csstype: ^3.0.10 peerDependenciesMeta: csstype: @@ -1684,8 +1702,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -1695,6 +1713,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1707,63 +1728,115 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.57.2': - resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.57.2 + '@typescript-eslint/parser': ^8.58.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.57.2': - resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + '@typescript-eslint/eslint-plugin@8.59.0': + resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.0 + 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==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.57.2': - resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.57.2': - resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.57.2': - resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + '@typescript-eslint/project-service@8.59.0': + resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.57.2': - resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + '@typescript-eslint/scope-manager@8.58.2': + 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==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.59.0': + resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.57.2': - resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.57.2': - resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.57.2': - resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + '@typescript-eslint/type-utils@8.59.0': + resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.57.2': - resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + '@typescript-eslint/types@8.58.2': + 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==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/typescript-estree@8.59.0': + resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + 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==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.2': + 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==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -1995,6 +2068,9 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2100,8 +2176,8 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} - dotenv@17.4.1: - resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -2181,11 +2257,11 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react-refresh@0.5.2: resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} @@ -2204,8 +2280,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.1.0: - resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -2288,9 +2364,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2408,8 +2493,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@17.4.0: - resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} engines: {node: '>=18'} goober@2.1.18: @@ -2466,8 +2551,8 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2479,8 +2564,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.12: - resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} + hono@4.12.14: + resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -2511,8 +2596,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@26.0.3: - resolution: {integrity: sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==} + i18next@26.0.7: + resolution: {integrity: sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -2644,8 +2729,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.36: - resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} + isbot@5.1.39: + resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} engines: {node: '>=18'} isexe@2.0.0: @@ -2771,24 +2856,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3003,10 +3092,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3021,8 +3106,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.13.2: - resolution: {integrity: sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A==} + msw@2.13.4: + resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3031,9 +3116,9 @@ packages: typescript: optional: true - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -3196,8 +3281,8 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} - postcss@8.5.9: - resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.1.0: @@ -3263,8 +3348,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true @@ -3320,8 +3405,8 @@ packages: peerDependencies: react: ^19.2.5 - react-i18next@17.0.2: - resolution: {integrity: sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==} + react-i18next@17.0.4: + resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} peerDependencies: i18next: '>= 26.0.1' react: '>= 16.8.0' @@ -3430,15 +3515,15 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + rettime@0.11.7: + resolution: {integrity: sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.0-rc.15: - resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3478,19 +3563,32 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.2: + resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.1: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} + seroval@1.5.2: + resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.2.0: - resolution: {integrity: sha512-ZDuV340itidaUd4Gi1BxQX+Y7Ush6BHp6URZBM2RyxUUBZ6yFtOWIr4nVY+Ro+YRSpo82v7JrsmtcU5xoBCMJQ==} + shadcn@4.3.0: + resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==} hasBin: true shebang-command@2.0.0: @@ -3686,20 +3784,20 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.57.2: - resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + typescript-eslint@8.59.0: + resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} @@ -3822,8 +3920,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.0.8: - resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + vite@8.0.10: + resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3897,10 +3995,6 @@ packages: resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} engines: {node: '>=20'} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3935,10 +4029,6 @@ packages: resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==} engines: {node: '>=18.19'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -4156,7 +4246,7 @@ snapshots: '@dotenvx/dotenvx@1.61.0': dependencies: commander: 11.1.0 - dotenv: 17.4.1 + dotenv: 17.4.2 eciesjs: 0.4.18 execa: 5.1.1 fdir: 6.5.0(picomatch@4.0.4) @@ -4170,13 +4260,13 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@emnapi/core@1.9.2': + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.2': + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true @@ -4264,38 +4354,38 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.6.1))': dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.3': + '@eslint/config-array@0.23.5': dependencies: - '@eslint/object-schema': 3.0.3 + '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.5.3': + '@eslint/config-helpers@0.5.5': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.1 - '@eslint/core@1.1.1': + '@eslint/core@1.2.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - '@eslint/object-schema@3.0.3': {} + '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.6.1': + '@eslint/plugin-kit@0.7.1': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.1 levn: 0.4.1 '@floating-ui/core@1.7.5': @@ -4317,9 +4407,9 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.13(hono@4.12.12)': + '@hono/node-server@1.19.14(hono@4.12.14)': dependencies: - hono: 4.12.12 + hono: 4.12.14 '@humanfs/core@0.19.1': {} @@ -4332,33 +4422,32 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/ansi@1.0.2': {} + '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@5.1.21(@types/node@25.5.0)': + '@inquirer/confirm@6.0.11(@types/node@25.6.0)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.5.0) - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@inquirer/core': 11.1.8(@types/node@25.6.0) + '@inquirer/type': 4.0.5(@types/node@25.6.0) optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/core@10.3.2(@types/node@25.5.0)': + '@inquirer/core@11.1.8(@types/node@25.6.0)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.5.0) + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.6.0) cli-width: 4.1.0 - mute-stream: 2.0.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 - '@inquirer/figures@1.0.15': {} + '@inquirer/figures@2.0.5': {} - '@inquirer/type@3.0.10(@types/node@25.5.0)': + '@inquirer/type@4.0.5(@types/node@25.6.0)': optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4381,7 +4470,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.12) + '@hono/node-server': 1.19.14(hono@4.12.14) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4391,7 +4480,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.12 + hono: 4.12.14 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -4410,10 +4499,10 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 optional: true @@ -4439,6 +4528,8 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@3.0.0': {} + '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 @@ -4446,7 +4537,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxc-project/types@0.124.0': {} + '@oxc-project/types@0.127.0': {} '@radix-ui/number@1.1.1': {} @@ -5195,56 +5286,56 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@rolldown/binding-android-arm64@1.0.0-rc.15': + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.15': + '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true - '@rolldown/pluginutils@1.0.0-rc.15': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} '@rolldown/pluginutils@1.0.0-rc.7': {} @@ -5325,39 +5416,39 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@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))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 8.0.8(@types/node@25.5.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.6.1)(tsx@4.21.0) '@tanstack/history@1.161.6': {} - '@tanstack/query-core@5.97.0': {} + '@tanstack/query-core@5.99.0': {} - '@tanstack/react-query@5.97.0(react@19.2.5)': + '@tanstack/react-query@5.99.0(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.97.0 + '@tanstack/query-core': 5.99.0 react: 19.2.5 - '@tanstack/react-router-devtools@1.166.11(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.7)(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.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)': dependencies: - '@tanstack/react-router': 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-devtools-core': 1.167.1(@tanstack/router-core@1.168.7)(csstype@3.2.3) + '@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) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: - '@tanstack/router-core': 1.168.7 + '@tanstack/router-core': 1.168.15 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router@1.168.23(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.7 - isbot: 5.1.36 + '@tanstack/router-core': 1.168.15 + isbot: 5.1.39 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -5368,6 +5459,13 @@ 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 @@ -5375,9 +5473,9 @@ snapshots: seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - '@tanstack/router-devtools-core@1.167.1(@tanstack/router-core@1.168.7)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.168.7 + '@tanstack/router-core': 1.168.15 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) optionalDependencies: @@ -5388,7 +5486,7 @@ snapshots: '@tanstack/router-core': 1.168.7 '@tanstack/router-utils': 1.161.6 '@tanstack/virtual-file-routes': 1.161.7 - prettier: 3.8.1 + prettier: 3.8.3 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -5396,7 +5494,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@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))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5412,8 +5510,8 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.168.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + '@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) transitivePeerDependencies: - supports-color @@ -5435,7 +5533,7 @@ snapshots: '@tanstack/virtual-file-routes@1.161.7': {} - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.2 @@ -5445,7 +5543,7 @@ snapshots: lodash-es: 4.17.23 minimatch: 9.0.9 parse-imports-exports: 0.2.4 - prettier: 3.8.1 + prettier: 3.8.3 transitivePeerDependencies: - supports-color @@ -5484,9 +5582,9 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.5.0': + '@types/node@25.6.0': dependencies: - undici-types: 7.18.2 + undici-types: 7.19.2 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -5496,6 +5594,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.6.0 + '@types/statuses@2.0.6': {} '@types/unist@2.0.11': {} @@ -5504,15 +5606,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@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)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(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/visitor-keys': 8.58.2 + eslint: 10.2.1(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5520,58 +5622,106 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@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)': dependencies: - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.57.2': - dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 - - '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) + '@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) + ignore: 7.0.5 + natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.57.2': {} - - '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 + '@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 debug: 4.4.3 - minimatch: 10.2.4 + eslint: 10.2.1(jiti@2.6.1) + 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 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) + '@typescript-eslint/types': 8.59.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + + '@typescript-eslint/scope-manager@8.59.0': + dependencies: + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 + + '@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)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(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) + debug: 4.4.3 + eslint: 10.2.1(jiti@2.6.1) + 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)': + 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) + debug: 4.4.3 + eslint: 10.2.1(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.2': {} + + '@typescript-eslint/types@8.59.0': {} + + '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 + debug: 4.4.3 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5579,28 +5729,59 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - eslint: 10.1.0(jiti@2.6.1) + '@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 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.57.2': + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.57.2 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@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) + 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)': + 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) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.2': + dependencies: + '@typescript-eslint/types': 8.58.2 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.59.0': + dependencies: + '@typescript-eslint/types': 8.59.0 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.5.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.6.1)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.8(@types/node@25.5.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.6.1)(tsx@4.21.0) accepts@2.0.0: dependencies: @@ -5799,6 +5980,8 @@ snapshots: cookie-es@2.0.0: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -5870,7 +6053,7 @@ snapshots: diff@8.0.4: {} - dotenv@17.4.1: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: dependencies: @@ -5953,24 +6136,24 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) 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.1.0(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-scope@9.1.2: dependencies: @@ -5983,14 +6166,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.1.0(jiti@2.6.1): + eslint@10.2.1(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.3 - '@eslint/config-helpers': 0.5.3 - '@eslint/core': 1.1.1 - '@eslint/plugin-kit': 0.6.1 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -6012,7 +6195,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -6131,8 +6314,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -6249,7 +6442,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@17.4.0: {} + globals@17.5.0: {} goober@2.1.18(csstype@3.2.3): dependencies: @@ -6357,7 +6550,10 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: {} + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 hermes-estree@0.25.1: {} @@ -6367,7 +6563,7 @@ snapshots: highlight.js@11.11.1: {} - hono@4.12.12: {} + hono@4.12.14: {} html-parse-stringify@3.0.1: dependencies: @@ -6400,9 +6596,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@26.0.3(typescript@5.9.3): - dependencies: - '@babel/runtime': 7.29.2 + i18next@26.0.7(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -6488,7 +6682,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.36: {} + isbot@5.1.39: {} isexe@2.0.0: {} @@ -6997,10 +7191,6 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.5 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -7013,20 +7203,20 @@ snapshots: ms@2.1.3: {} - msw@2.13.2(@types/node@25.5.0)(typescript@5.9.3): + msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@25.5.0) + '@inquirer/confirm': 6.0.11(@types/node@25.6.0) '@mswjs/interceptors': 0.41.3 - '@open-draft/deferred-promise': 2.2.0 + '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 graphql: 16.13.2 - headers-polyfill: 4.0.3 + headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.10.1 + rettime: 0.11.7 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.1 @@ -7038,7 +7228,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - mute-stream@2.0.0: {} + mute-stream@3.0.0: {} nanoid@3.3.11: {} @@ -7196,7 +7386,7 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.9: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -7206,13 +7396,13 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1): + prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3): dependencies: - prettier: 3.8.1 + prettier: 3.8.3 optionalDependencies: - '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1) + '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3) - prettier@3.8.1: {} + prettier@3.8.3: {} pretty-ms@9.3.0: dependencies: @@ -7315,11 +7505,11 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-i18next@17.0.2(i18next@26.0.3(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.7(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.3(typescript@5.9.3) + i18next: 26.0.7(typescript@5.9.3) react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: @@ -7460,30 +7650,30 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.10.1: {} + rettime@0.11.7: {} reusify@1.1.0: {} - rolldown@1.0.0-rc.15: + rolldown@1.0.0-rc.17: dependencies: - '@oxc-project/types': 0.124.0 - '@rolldown/pluginutils': 1.0.0-rc.15 + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-x64': 1.0.0-rc.15 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 router@2.2.0: dependencies: @@ -7529,8 +7719,14 @@ snapshots: dependencies: seroval: 1.5.1 + seroval-plugins@1.5.2(seroval@1.5.2): + dependencies: + seroval: 1.5.2 + seroval@1.5.1: {} + seroval@1.5.2: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -7540,9 +7736,11 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.0: {} + setprototypeof@1.2.0: {} - shadcn@4.2.0(@types/node@25.5.0)(typescript@5.9.3): + shadcn@4.3.0(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 @@ -7563,11 +7761,11 @@ snapshots: fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.13.2(@types/node@25.5.0)(typescript@5.9.3) + msw: 2.13.4(@types/node@25.6.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.9 + postcss: 8.5.10 postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 @@ -7769,20 +7967,20 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.1.0(jiti@2.6.1) + '@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: 5.9.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} - undici-types@7.18.2: {} + undici-types@7.19.2: {} unicorn-magic@0.3.0: {} @@ -7906,15 +8104,15 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.8(@types/node@25.5.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.6.1)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.9 - rolldown: 1.0.0-rc.15 + postcss: 8.5.10 + rolldown: 1.0.0-rc.17 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 esbuild: 0.27.4 fsevents: 2.3.3 jiti: 2.6.1 @@ -7944,12 +8142,6 @@ snapshots: string-width: 8.2.0 strip-ansi: 7.2.0 - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -7985,8 +8177,6 @@ snapshots: dependencies: yoctocolors: 2.1.2 - yoctocolors-cjs@2.1.3: {} - yoctocolors@2.1.2: {} zod-to-json-schema@3.25.2(zod@3.25.76): diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts index d6bd93c4d..c7318d962 100644 --- a/web/frontend/src/api/launcher-auth.ts +++ b/web/frontend/src/api/launcher-auth.ts @@ -2,16 +2,26 @@ * Dashboard launcher auth API. * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages. */ +export type LoginResult = + | { ok: true } + | { ok: false; status: number; error: string } + export async function postLauncherDashboardLogin( password: string, -): Promise { +): Promise { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ password: password.trim() }), }) - return res.ok + if (res.ok) return { ok: true } + + return { + ok: false, + status: res.status, + error: await readLauncherAuthError(res), + } } export type LauncherAuthStatus = { @@ -57,12 +67,16 @@ export async function postLauncherDashboardSetup( }), }) if (res.ok) return { ok: true } - let msg = "Unknown error" + return { ok: false, error: await readLauncherAuthError(res) } +} + +async function readLauncherAuthError(res: Response): Promise { + let msg = `Request failed with status ${res.status}` try { const j = (await res.json()) as { error?: string } if (j.error) msg = j.error } catch { /* ignore */ } - return { ok: false, error: msg } + return msg } diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index bfdd80d6d..d2d2dca88 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway" export interface ModelInfo { index: number model_name: string + provider?: string model: string api_base?: string api_key: string diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts index 6b8ceb49a..ca98a06da 100644 --- a/web/frontend/src/api/pico.ts +++ b/web/frontend/src/api/pico.ts @@ -2,16 +2,16 @@ import { launcherFetch } from "@/api/http" // API client for Pico Channel configuration. -interface PicoTokenResponse { - token: string +interface PicoInfoResponse { ws_url: string enabled: boolean + configured?: boolean } interface PicoSetupResponse { - token: string ws_url: string enabled: boolean + configured?: boolean changed: boolean } @@ -25,16 +25,16 @@ async function request(path: string, options?: RequestInit): Promise { return res.json() as Promise } -export async function getPicoToken(): Promise { - return request("/api/pico/token") +export async function getPicoInfo(): Promise { + return request("/api/pico/info") } -export async function regenPicoToken(): Promise { - return request("/api/pico/token", { method: "POST" }) +export async function regenPicoToken(): Promise { + return request("/api/pico/token", { method: "POST" }) } export async function setupPico(): Promise { return request("/api/pico/setup", { method: "POST" }) } -export type { PicoTokenResponse, PicoSetupResponse } +export type { PicoInfoResponse, PicoSetupResponse } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index dd0fa1f53..edd7d7c27 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -14,7 +14,25 @@ export interface SessionDetail { messages: { role: "user" | "assistant" content: string + kind?: "normal" | "thought" | "tool_calls" media?: string[] + attachments?: { + type?: "image" | "audio" | "video" | "file" + url: string + filename?: string + content_type?: string + }[] + tool_calls?: { + id?: string + type?: string + function?: { + name?: string + arguments?: string + } + extra_content?: { + tool_feedback_explanation?: string + } + }[] }[] summary: string created: string diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 8623c7e78..dfc48b6b8 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -11,7 +11,6 @@ export interface LauncherConfig { port: number public: boolean allowed_cidrs: string[] - launcher_token: string } export interface SystemVersionInfo { diff --git a/web/frontend/src/components/agent/tools/tool-library-tab.tsx b/web/frontend/src/components/agent/tools/tool-library-tab.tsx new file mode 100644 index 000000000..6bbfeb091 --- /dev/null +++ b/web/frontend/src/components/agent/tools/tool-library-tab.tsx @@ -0,0 +1,270 @@ +import { IconSearch, IconSettings } from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { ToolSupportItem } from "@/api/tools" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +import { ToolStatusBadge } from "./tool-status-badge" +import type { GroupedTools, ToolStatusFilter } from "./types" + +interface ToolLibraryTabProps { + allTools: ToolSupportItem[] + groupedTools: GroupedTools + totalFilteredCount: number + searchQuery: string + statusFilter: ToolStatusFilter + isLoading: boolean + hasError: boolean + pendingToolName: string | null + onSearchQueryChange: (value: string) => void + onStatusFilterChange: (value: ToolStatusFilter) => void + onOpenWebSearchSettings: () => void + onToggleTool: (name: string, enabled: boolean) => void +} + +export function ToolLibraryTab({ + allTools, + groupedTools, + totalFilteredCount, + searchQuery, + statusFilter, + isLoading, + hasError, + pendingToolName, + onSearchQueryChange, + onStatusFilterChange, + onOpenWebSearchSettings, + onToggleTool, +}: ToolLibraryTabProps) { + const { t } = useTranslation() + + return ( +
+
+
+

+ {t("pages.agent.tools.library_title", "Tool Library")} +

+

+ {t( + "pages.agent.tools.library_description", + "Browse and manage the toolset available to your AI agents.", + )} +

+
+ +
+
+ + onSearchQueryChange(event.target.value)} + /> +
+ + +
+
+ + {hasError ? ( +
+

+ {t("pages.agent.load_error", "Failed to load tools")} +

+
+ ) : isLoading ? ( + + ) : totalFilteredCount === 0 ? ( + + ) : ( +
+ {groupedTools.map(([category, items]) => ( +
+
+

+ {t(`pages.agent.tools.categories.${category}`, category)} +

+
+
+ {items.map((tool) => ( + + ))} +
+
+ ))} +
+ )} +
+ ) +} + +function ToolCard({ + tool, + isPending, + onOpenWebSearchSettings, + onToggleTool, +}: { + tool: ToolSupportItem + isPending: boolean + onOpenWebSearchSettings: () => void + onToggleTool: (name: string, enabled: boolean) => void +}) { + const { t } = useTranslation() + const reasonText = tool.reason_code + ? t(`pages.agent.tools.reasons.${tool.reason_code}`) + : "" + const isEnabled = tool.status === "enabled" + const isToggledOn = tool.status !== "disabled" + const isDisabled = tool.status === "disabled" + const isBlocked = tool.status === "blocked" + const isWebSearchTool = tool.name === "web_search" + + return ( + + +
+
+

+ {tool.name} +

+ +
+
+ {isWebSearchTool && ( + + )} + onToggleTool(tool.name, checked)} + className={cn( + "shrink-0", + isEnabled && "shadow-xs ring-1 ring-emerald-500/20", + )} + /> +
+
+ +

+ {tool.description} +

+ + {reasonText && ( +
+
+ {reasonText} +
+
+ )} +
+
+ ) +} + +function LibraryLoadingState() { + return ( +
+ {[1, 2].map((groupIndex) => ( +
+ +
+ {[1, 2].map((itemIndex) => ( + + ))} +
+
+ ))} +
+ ) +} + +function LibraryEmptyState({ allToolsCount }: { allToolsCount: number }) { + const { t } = useTranslation() + + return ( +
+
+ +
+

+ {allToolsCount === 0 + ? t("pages.agent.tools.empty", "No tools found") + : t("pages.agent.tools.no_results", "No matching tools")} +

+ {allToolsCount !== 0 && ( +

+ Try adjusting your search criteria or status filters. +

+ )} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/tool-status-badge.tsx b/web/frontend/src/components/agent/tools/tool-status-badge.tsx new file mode 100644 index 000000000..017d167b2 --- /dev/null +++ b/web/frontend/src/components/agent/tools/tool-status-badge.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next" + +import type { ToolSupportItem } from "@/api/tools" +import { cn } from "@/lib/utils" + +interface ToolStatusBadgeProps { + status: ToolSupportItem["status"] +} + +export function ToolStatusBadge({ status }: ToolStatusBadgeProps) { + const { t } = useTranslation() + + return ( + + {t(`pages.agent.tools.status.${status}`, status)} + + ) +} diff --git a/web/frontend/src/components/agent/tools/tools-page.tsx b/web/frontend/src/components/agent/tools/tools-page.tsx index 634dd1b7f..c221f911c 100644 --- a/web/frontend/src/components/agent/tools/tools-page.tsx +++ b/web/frontend/src/components/agent/tools/tools-page.tsx @@ -1,574 +1,87 @@ -import { IconSearch } from "@tabler/icons-react" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { useEffect, useMemo, useState } from "react" +import { useLayoutEffect, useRef } from "react" import { useTranslation } from "react-i18next" -import { toast } from "sonner" -import { - getTools, - getWebSearchConfig, - setToolEnabled, - type ToolSupportItem, - type WebSearchConfigResponse, - updateWebSearchConfig, -} from "@/api/tools" import { PageHeader } from "@/components/page-header" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" -import { KeyInput } from "@/components/shared-form" -import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { Skeleton } from "@/components/ui/skeleton" -import { Switch } from "@/components/ui/switch" -import { cn } from "@/lib/utils" -import { refreshGatewayState } from "@/store/gateway" + +import { ToolLibraryTab } from "./tool-library-tab" +import { ToolsTabs } from "./tools-tabs" +import { useToolsPage } from "./use-tools-page" +import { WebSearchTab } from "./web-search-tab" export function ToolsPage() { const { t } = useTranslation() - const queryClient = useQueryClient() - const { data, isLoading, error } = useQuery({ - queryKey: ["tools"], - queryFn: getTools, - }) + const scrollContainerRef = useRef(null) const { - data: webSearchData, - isLoading: isWebSearchLoading, - error: webSearchError, - } = useQuery({ - queryKey: ["tools", "web-search-config"], - queryFn: getWebSearchConfig, - }) + activeTab, + expandedProvider, + groupedTools, + pendingToolName, + providerLabelMap, + searchQuery, + statusFilter, + tools, + totalFilteredCount, + webSearchDraft, + hasToolsError, + hasWebSearchError, + isToolsLoading, + isWebSearchLoading, + isWebSearchSaving, + isWebSearchDirty, + setActiveTab, + setSearchQuery, + setStatusFilter, + saveWebSearchConfig, + toggleExpandedProvider, + toggleTool, + updateWebSearchDraft, + } = useToolsPage() - const [searchQuery, setSearchQuery] = useState("") - const [statusFilter, setStatusFilter] = useState("all") - const [webSearchDraft, setWebSearchDraft] = - useState(null) - - useEffect(() => { - if (webSearchData) { - setWebSearchDraft(webSearchData) - } - }, [webSearchData]) - - const toggleMutation = useMutation({ - mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => - setToolEnabled(name, enabled), - onSuccess: (_, variables) => { - toast.success( - variables.enabled - ? t("pages.agent.tools.enable_success") - : t("pages.agent.tools.disable_success"), - ) - void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) - }, - onError: (err) => { - toast.error( - err instanceof Error - ? err.message - : t("pages.agent.tools.toggle_error"), - ) - }, - }) - - const webSearchMutation = useMutation({ - mutationFn: updateWebSearchConfig, - onSuccess: (updated) => { - setWebSearchDraft(updated) - toast.success(t("pages.agent.tools.web_search.save_success")) - void queryClient.invalidateQueries({ queryKey: ["tools", "web-search-config"] }) - void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) - }, - onError: (err) => { - toast.error( - err instanceof Error - ? err.message - : t("pages.agent.tools.web_search.save_error"), - ) - }, - }) - - // Filter and group tools - const { groupedTools, totalFilteredCount } = useMemo(() => { - if (!data) return { groupedTools: [], totalFilteredCount: 0 } - - let count = 0 - const buckets = new Map() - - for (const item of data.tools) { - // Apply status filter - if (statusFilter !== "all" && item.status !== statusFilter) continue - - // Apply search query - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase() - const matchesName = item.name.toLowerCase().includes(query) - const matchesDesc = (item.description || "") - .toLowerCase() - .includes(query) - if (!matchesName && !matchesDesc) continue - } - - count++ - const list = buckets.get(item.category) ?? [] - list.push(item) - buckets.set(item.category, list) - } - - return { - groupedTools: Array.from(buckets.entries()), - totalFilteredCount: count, - } - }, [data, searchQuery, statusFilter]) - - const providerLabelMap = useMemo(() => { - const entries = webSearchDraft?.providers ?? [] - return new Map(entries.map((item) => [item.id, item.label])) - }, [webSearchDraft]) - - const currentProviderLabel = webSearchDraft?.current_service - ? (providerLabelMap.get(webSearchDraft.current_service) ?? - webSearchDraft.current_service) - : t("pages.agent.tools.web_search.none") - - const updateDraft = ( - updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, - ) => { - setWebSearchDraft((current) => (current ? updater(current) : current)) - } + useLayoutEffect(() => { + scrollContainerRef.current?.scrollTo({ top: 0 }) + }, [activeTab]) return (
- + + -
-
- {webSearchError ? ( - - - {t("pages.agent.tools.web_search.title")} - {t("pages.agent.tools.web_search.load_error")} - - - ) : isWebSearchLoading || !webSearchDraft ? ( - - - - - - - - - - - +
+
+ {activeTab === "library" ? ( + setActiveTab("web-search")} + onToggleTool={toggleTool} + /> ) : ( - - - {t("pages.agent.tools.web_search.title")} - - {t("pages.agent.tools.web_search.description")} - - - -
-
-
- {t("pages.agent.tools.web_search.current_service")} -
-
- {currentProviderLabel} -
-
-
-
- {t("pages.agent.tools.web_search.provider")} -
- -
-
-
- {t("pages.agent.tools.web_search.proxy")} -
- - updateDraft((current) => ({ - ...current, - proxy: e.target.value, - })) - } - placeholder="http://127.0.0.1:7890" - /> -
-
- -
-
-
- {t("pages.agent.tools.web_search.prefer_native")} -
-
- {t("pages.agent.tools.web_search.prefer_native_hint")} -
-
- - updateDraft((current) => ({ - ...current, - prefer_native: checked, - })) - } - /> -
- -
- {Object.entries(webSearchDraft.settings).map(([providerId, settings]) => { - const providerLabel = providerLabelMap.get(providerId) ?? providerId - const apiKeyPlaceholder = maskedSecretPlaceholder( - settings.api_key_set ? `${providerId}-configured` : "", - t("pages.agent.tools.web_search.api_key_placeholder"), - ) - - return ( - - -
-
- {providerLabel} - - {t("pages.agent.tools.web_search.provider_hint")} - -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - enabled: checked, - }, - }, - })) - } - /> -
-
- -
-
- {t("pages.agent.tools.web_search.max_results")} -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - max_results: Number(e.target.value) || 0, - }, - }, - })) - } - /> -
- {(providerId === "tavily" || - providerId === "searxng" || - providerId === "glm_search" || - providerId === "baidu_search") && ( -
-
- {t("pages.agent.tools.web_search.base_url")} -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - base_url: e.target.value, - }, - }, - })) - } - placeholder={t("pages.agent.tools.web_search.base_url_placeholder")} - /> -
- )} - {(providerId === "brave" || - providerId === "tavily" || - providerId === "perplexity" || - providerId === "glm_search" || - providerId === "baidu_search") && ( -
-
- {t("pages.agent.tools.web_search.api_key")} -
- - updateDraft((current) => ({ - ...current, - settings: { - ...current.settings, - [providerId]: { - ...current.settings[providerId], - api_key: value, - }, - }, - })) - } - placeholder={apiKeyPlaceholder} - /> -
- )} -
-
- ) - })} -
- -
- -
-
-
- )} - - {/* Header & Description */} -
- {/* Filters Toolbar */} -
-
- - setSearchQuery(e.target.value)} - /> -
- -
-
- - {/* Content Area */} - {error ? ( - - -

- {t("pages.agent.load_error")} -

-
-
- ) : isLoading ? ( - // Skeleton Loading State -
- {[1, 2].map((groupIndex) => ( -
- -
- {[1, 2, 3, 4].map((itemIndex) => ( - - - - - - - - - - - ))} -
-
- ))} -
- ) : totalFilteredCount === 0 ? ( - // Empty State - - -
- -
-

- {data?.tools.length === 0 - ? t("pages.agent.tools.empty") - : t("pages.agent.tools.no_results")} -

- {data?.tools.length !== 0 && ( -

- Try adjusting your search criteria or status filters. -

- )} -
-
- ) : ( - // Tool Categories list -
- {groupedTools.map(([category, items]) => ( -
-

- {t(`pages.agent.tools.categories.${category}`)} -

-
- {items.map((tool) => { - const reasonText = tool.reason_code - ? t(`pages.agent.tools.reasons.${tool.reason_code}`) - : "" - const isPending = - toggleMutation.isPending && - toggleMutation.variables?.name === tool.name - const isEnabled = tool.status === "enabled" - const isDisabled = tool.status === "disabled" - const isBlocked = tool.status === "blocked" - - return ( - - -
-
-
- - {tool.name} - - -
- - {tool.description} - -
-
- - toggleMutation.mutate({ - name: tool.name, - enabled: checked, - }) - } - /> -
-
-
- {reasonText && ( - -
- {reasonText} -
-
- )} -
- ) - })} -
-
- ))} -
+ )}
) } - -function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) { - const { t } = useTranslation() - - return ( - - {t(`pages.agent.tools.status.${status}`)} - - ) -} diff --git a/web/frontend/src/components/agent/tools/tools-tabs.tsx b/web/frontend/src/components/agent/tools/tools-tabs.tsx new file mode 100644 index 000000000..a5898ccdc --- /dev/null +++ b/web/frontend/src/components/agent/tools/tools-tabs.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from "react-i18next" + +import { cn } from "@/lib/utils" + +import type { ToolsPageTab } from "./types" + +interface ToolsTabsProps { + activeTab: ToolsPageTab + onChange: (tab: ToolsPageTab) => void +} + +const tabs: Array<{ + defaultLabel: string + key: ToolsPageTab + translationKey: string +}> = [ + { + key: "library", + translationKey: "pages.agent.tools.library_title", + defaultLabel: "Tool Library", + }, + { + key: "web-search", + translationKey: "pages.agent.tools.web_search.title", + defaultLabel: "Web Search", + }, +] + +export function ToolsTabs({ activeTab, onChange }: ToolsTabsProps) { + const { t } = useTranslation() + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ ) +} diff --git a/web/frontend/src/components/agent/tools/types.ts b/web/frontend/src/components/agent/tools/types.ts new file mode 100644 index 000000000..1aec90931 --- /dev/null +++ b/web/frontend/src/components/agent/tools/types.ts @@ -0,0 +1,9 @@ +import type { ToolSupportItem, WebSearchConfigResponse } from "@/api/tools" + +export type ToolsPageTab = "library" | "web-search" +export type ToolStatusFilter = "all" | ToolSupportItem["status"] +export type GroupedTools = Array<[string, ToolSupportItem[]]> + +export type WebSearchDraftUpdater = ( + updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, +) => void diff --git a/web/frontend/src/components/agent/tools/use-tools-page.ts b/web/frontend/src/components/agent/tools/use-tools-page.ts new file mode 100644 index 000000000..ecc433b0e --- /dev/null +++ b/web/frontend/src/components/agent/tools/use-tools-page.ts @@ -0,0 +1,209 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useDeferredValue, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type WebSearchConfigResponse, + getTools, + getWebSearchConfig, + setToolEnabled, + updateWebSearchConfig, +} from "@/api/tools" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" +import { refreshGatewayState } from "@/store/gateway" + +import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types" + +export function useToolsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const [activeTab, setActiveTab] = useState("library") + const [searchQuery, setSearchQuery] = useState("") + const deferredSearchQuery = useDeferredValue(searchQuery) + const [statusFilter, setStatusFilter] = useState("all") + const [expandedProvider, setExpandedProvider] = useState(null) + const [webSearchDraftOverride, setWebSearchDraftOverride] = + useState(null) + + const toolsQuery = useQuery({ + queryKey: ["tools"], + queryFn: getTools, + }) + const webSearchQuery = useQuery({ + queryKey: ["tools", "web-search-config"], + queryFn: getWebSearchConfig, + }) + + const tools = useMemo( + () => toolsQuery.data?.tools ?? [], + [toolsQuery.data?.tools], + ) + const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() + const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null + const isWebSearchDirty = useMemo(() => { + if (!webSearchDraft || !webSearchQuery.data) { + return false + } + return ( + JSON.stringify(webSearchDraft) !== JSON.stringify(webSearchQuery.data) + ) + }, [webSearchDraft, webSearchQuery.data]) + + const toggleToolMutation = useMutation({ + mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => + setToolEnabled(name, enabled), + onSuccess: async (_, variables) => { + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + variables.enabled + ? t("pages.agent.tools.enable_success", "Tool enabled successfully") + : t( + "pages.agent.tools.disable_success", + "Tool disabled successfully", + ), + t("navigation.tools", "Tools"), + gateway?.restartRequired === true, + ) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t("pages.agent.tools.toggle_error", "Failed to toggle tool"), + ) + }, + }) + + const saveWebSearchMutation = useMutation({ + mutationFn: updateWebSearchConfig, + onSuccess: async (updatedConfig) => { + queryClient.setQueryData(["tools", "web-search-config"], updatedConfig) + setWebSearchDraftOverride(null) + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t( + "pages.agent.tools.web_search.save_success", + "Settings saved successfully", + ), + t("pages.agent.tools.web_search.title", "Web Search Configuration"), + gateway?.restartRequired === true, + ) + void queryClient.invalidateQueries({ + queryKey: ["tools", "web-search-config"], + }) + void queryClient.invalidateQueries({ queryKey: ["tools"] }) + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : t( + "pages.agent.tools.web_search.save_error", + "Failed to save settings", + ), + ) + }, + }) + + const groupedTools = useMemo<{ + groupedTools: GroupedTools + totalFilteredCount: number + }>(() => { + let totalFilteredCount = 0 + const grouped = new Map() + + for (const tool of tools) { + if (statusFilter !== "all" && tool.status !== statusFilter) { + continue + } + + if (normalizedSearchQuery) { + const matchesName = tool.name + .toLowerCase() + .includes(normalizedSearchQuery) + const matchesDescription = (tool.description || "") + .toLowerCase() + .includes(normalizedSearchQuery) + + if (!matchesName && !matchesDescription) { + continue + } + } + + totalFilteredCount += 1 + const items = grouped.get(tool.category) ?? [] + items.push(tool) + grouped.set(tool.category, items) + } + + return { + groupedTools: Array.from(grouped.entries()), + totalFilteredCount, + } + }, [normalizedSearchQuery, statusFilter, tools]) + + const providerLabelMap = useMemo(() => { + const providers = webSearchDraft?.providers ?? [] + return new Map(providers.map((provider) => [provider.id, provider.label])) + }, [webSearchDraft]) + + const pendingToolName = toggleToolMutation.isPending + ? (toggleToolMutation.variables?.name ?? null) + : null + + const updateWebSearchDraft = ( + updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse, + ) => { + setWebSearchDraftOverride((current) => { + const draft = current ?? webSearchQuery.data + return draft ? updater(draft) : current + }) + } + + const toggleTool = (name: string, enabled: boolean) => { + toggleToolMutation.mutate({ name, enabled }) + } + + const saveWebSearchConfig = () => { + if (webSearchDraft) { + saveWebSearchMutation.mutate(webSearchDraft) + } + } + + const toggleExpandedProvider = (providerId: string) => { + setExpandedProvider((current) => + current === providerId ? null : providerId, + ) + } + + return { + activeTab, + expandedProvider, + groupedTools: groupedTools.groupedTools, + pendingToolName, + providerLabelMap, + searchQuery, + statusFilter, + tools, + totalFilteredCount: groupedTools.totalFilteredCount, + webSearchDraft, + hasToolsError: toolsQuery.error != null, + hasWebSearchError: webSearchQuery.error != null, + isToolsLoading: toolsQuery.isLoading, + isWebSearchLoading: webSearchQuery.isLoading, + isWebSearchSaving: saveWebSearchMutation.isPending, + isWebSearchDirty, + setActiveTab, + setSearchQuery, + setStatusFilter, + saveWebSearchConfig, + toggleExpandedProvider, + toggleTool, + updateWebSearchDraft, + } +} diff --git a/web/frontend/src/components/agent/tools/web-search-general-settings.tsx b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx new file mode 100644 index 000000000..f3c8004b5 --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" + +import type { WebSearchConfigResponse } from "@/api/tools" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" + +import type { WebSearchDraftUpdater } from "./types" + +interface WebSearchGeneralSettingsProps { + draft: WebSearchConfigResponse + onUpdateDraft: WebSearchDraftUpdater +} + +export function WebSearchGeneralSettings({ + draft, + onUpdateDraft, +}: WebSearchGeneralSettingsProps) { + const { t } = useTranslation() + + return ( +
+

+ {t("pages.agent.tools.web_search.global_settings", "General")} +

+ +
+ + + + + + + onUpdateDraft((current) => ({ + ...current, + proxy: event.target.value, + })) + } + placeholder="http://127.0.0.1:7890" + /> + + + + + onUpdateDraft((current) => ({ + ...current, + prefer_native: checked, + })) + } + className="data-[state=checked]:shadow-xs" + /> + +
+
+ ) +} + +function SettingRow({ + label, + description, + children, +}: { + label: string + description: string + children: ReactNode +}) { + return ( +
+
+ +

+ {description} +

+
+ {children} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx new file mode 100644 index 000000000..9ba8d6ac6 --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx @@ -0,0 +1,253 @@ +import { IconChevronDown } from "@tabler/icons-react" +import type { ReactNode } from "react" +import { useTranslation } from "react-i18next" + +import type { WebSearchProviderConfig } from "@/api/tools" +import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { KeyInput } from "@/components/shared-form" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" + +import type { WebSearchDraftUpdater } from "./types" + +interface WebSearchProviderSettingsProps { + providerLabelMap: Map + settings: Record + expandedProvider: string | null + onToggleProviderExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +} + +const baseUrlProviders = new Set([ + "tavily", + "searxng", + "glm_search", + "baidu_search", +]) + +const apiKeyProviders = new Set([ + "brave", + "tavily", + "perplexity", + "glm_search", + "baidu_search", +]) + +export function WebSearchProviderSettings({ + providerLabelMap, + settings, + expandedProvider, + onToggleProviderExpand, + onUpdateDraft, +}: WebSearchProviderSettingsProps) { + const { t } = useTranslation() + + return ( +
+

+ {t("pages.agent.tools.web_search.providers_config", "Integrations")} +

+ +
+ {Object.entries(settings).map(([providerId, providerSettings]) => ( + + ))} +
+
+ ) +} + +function ProviderCard({ + providerId, + providerLabel, + settings, + isExpanded, + onToggleExpand, + onUpdateDraft, +}: { + providerId: string + providerLabel: string + settings: WebSearchProviderConfig + isExpanded: boolean + onToggleExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +}) { + const { t } = useTranslation() + const apiKeyPlaceholder = maskedSecretPlaceholder( + settings.api_key_set ? `${providerId}-configured` : "", + t( + "pages.agent.tools.web_search.api_key_placeholder", + "Enter API key...", + ), + ) + + const updateSettings = ( + updater: (current: WebSearchProviderConfig) => WebSearchProviderConfig, + ) => { + onUpdateDraft((current) => { + const nextSettings = current.settings[providerId] ?? settings + return { + ...current, + settings: { + ...current.settings, + [providerId]: updater(nextSettings), + }, + } + }) + } + + return ( +
+
+ + +
event.stopPropagation()} + > + + updateSettings((current) => ({ + ...current, + enabled: checked, + })) + } + /> +
+
+ + {isExpanded && ( +
+
+ + + updateSettings((current) => ({ + ...current, + max_results: Number(event.target.value) || 0, + })) + } + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors" + /> + + + {baseUrlProviders.has(providerId) && ( + + + updateSettings((current) => ({ + ...current, + base_url: event.target.value, + })) + } + placeholder={t( + "pages.agent.tools.web_search.base_url_placeholder", + "Optional endpoint override", + )} + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors" + /> + + )} + + {apiKeyProviders.has(providerId) && ( + + + updateSettings((current) => ({ + ...current, + api_key: value, + })) + } + placeholder={apiKeyPlaceholder} + className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent transition-colors" + /> + + )} +
+
+ )} +
+ ) +} + +function ProviderField({ + label, + className, + children, +}: { + label: string + className?: string + children: ReactNode +}) { + return ( +
+ + {children} +
+ ) +} diff --git a/web/frontend/src/components/agent/tools/web-search-tab.tsx b/web/frontend/src/components/agent/tools/web-search-tab.tsx new file mode 100644 index 000000000..866e0f27f --- /dev/null +++ b/web/frontend/src/components/agent/tools/web-search-tab.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from "react-i18next" + +import type { WebSearchConfigResponse } from "@/api/tools" +import { ConfigChangeNotice } from "@/components/config-change-notice" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" + +import type { WebSearchDraftUpdater } from "./types" +import { WebSearchGeneralSettings } from "./web-search-general-settings" +import { WebSearchProviderSettings } from "./web-search-provider-settings" + +interface WebSearchTabProps { + draft: WebSearchConfigResponse | null + providerLabelMap: Map + expandedProvider: string | null + isLoading: boolean + hasError: boolean + isSaving: boolean + isDirty: boolean + onSave: () => void + onToggleProviderExpand: (providerId: string) => void + onUpdateDraft: WebSearchDraftUpdater +} + +export function WebSearchTab({ + draft, + providerLabelMap, + expandedProvider, + isLoading, + hasError, + isSaving, + isDirty, + onSave, + onToggleProviderExpand, + onUpdateDraft, +}: WebSearchTabProps) { + const { t } = useTranslation() + + return ( +
+ {hasError ? ( +
+

+ {t( + "pages.agent.tools.web_search.load_error", + "Failed to load web search configuration", + )} +

+
+ ) : isLoading || !draft ? ( + + ) : ( + <> +
+
+

+ {t( + "pages.agent.tools.web_search.title", + "Web Search Configuration", + )} +

+

+ {t( + "pages.agent.tools.web_search.description", + "Configure how the web search tool behaves by default, including whether the model may use its built-in search capability.", + )} +

+
+ + +
+ + {isDirty && ( + + )} + +
+ + +
+ + )} +
+ ) +} + +function LoadingState() { + return ( +
+ + +
+ ) +} diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index e94975075..465d218be 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -295,6 +295,22 @@ export function AppHeader() { {/* Theme Toggle */} + + + + + {/* Logout */}
) diff --git a/web/frontend/src/components/channels/channel-array-list-field.tsx b/web/frontend/src/components/channels/channel-array-list-field.tsx new file mode 100644 index 000000000..ff601c07b --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-list-field.tsx @@ -0,0 +1,180 @@ +import { IconX } from "@tabler/icons-react" +import { + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" + +import { + mergeUniqueStringItems, + parseConservativeStringListInput, +} from "@/components/channels/channel-array-utils" +import { Field } from "@/components/shared-form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +type StringListParser = (raw: string) => string[] +export type ArrayFieldFlusher = () => string[] | null + +type RegisterArrayFieldFlusher = ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, +) => void + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false + } + return left.every((item, index) => item === right[index]) +} + +interface ChannelArrayListFieldProps { + label: string + hint?: string + error?: string + required?: boolean + value: string[] + onChange: (value: string[]) => void + placeholder?: string + parser?: StringListParser + fieldPath?: string + registerFlusher?: RegisterArrayFieldFlusher + resetVersion?: number +} + +export function ChannelArrayListField({ + label, + hint, + error, + required, + value, + onChange, + placeholder, + parser = parseConservativeStringListInput, + fieldPath, + registerFlusher, + resetVersion, +}: ChannelArrayListFieldProps) { + const { t } = useTranslation() + const [draft, setDraft] = useState("") + const draftRef = useRef("") + const valueRef = useRef(value) + const localValueRef = useRef(value) + const parserRef = useRef(parser) + const onChangeRef = useRef(onChange) + + useEffect(() => { + valueRef.current = value + localValueRef.current = value + }, [value]) + + useEffect(() => { + draftRef.current = "" + setDraft("") + }, [resetVersion]) + + useEffect(() => { + parserRef.current = parser + }, [parser]) + + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + const commitDraft = useCallback(() => { + const rawDraft = draftRef.current + if (rawDraft.trim() === "") { + if (!areStringArraysEqual(localValueRef.current, valueRef.current)) { + return localValueRef.current + } + draftRef.current = "" + setDraft("") + return null + } + draftRef.current = "" + setDraft("") + const nextItems = parserRef.current(rawDraft) + if (nextItems.length === 0) { + return null + } + const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems) + localValueRef.current = mergedItems + onChangeRef.current(mergedItems) + return mergedItems + }, []) + + useEffect(() => { + if (!fieldPath || !registerFlusher) { + return + } + registerFlusher(fieldPath, commitDraft) + return () => registerFlusher(fieldPath, null) + }, [commitDraft, fieldPath, registerFlusher]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") { + return + } + event.preventDefault() + commitDraft() + } + + const handleRemove = (index: number) => { + const nextValue = value.filter((_, itemIndex) => itemIndex !== index) + localValueRef.current = nextValue + onChangeRef.current(nextValue) + } + + return ( + +
+ {value.length > 0 && ( +
+ {value.map((item, index) => ( + + {item} + + + ))} +
+ )} + +
+ { + const nextDraft = event.target.value + draftRef.current = nextDraft + setDraft(nextDraft) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> + +
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-array-utils.ts b/web/frontend/src/components/channels/channel-array-utils.ts new file mode 100644 index 000000000..0f6268be8 --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-utils.ts @@ -0,0 +1,72 @@ +const ALLOW_FROM_HIDDEN_CHARS_RE = + /\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g + +function normalizeStringListItems( + items: string[], + options: { stripHiddenChars?: boolean } = {}, +): string[] { + const result: string[] = [] + const seen = new Set() + + for (const item of items) { + const normalized = options.stripHiddenChars + ? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "") + : item + const trimmed = normalized.trim() + if (trimmed.length === 0 || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + result.push(trimmed) + } + + return result +} + +function splitStringList( + raw: string, + separators: RegExp, + options: { stripHiddenChars?: boolean } = {}, +): string[] { + if (raw.trim() === "") { + return [] + } + return normalizeStringListItems(raw.split(separators), options) +} + +export function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((item): item is string => typeof item === "string") +} + +export function parseAllowFromInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, { + stripHiddenChars: true, + }) +} + +export function parseConservativeStringListInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C\n\r\t]+/) +} + +export function normalizeAllowFromValues(value: unknown): string[] { + return normalizeStringListItems(asStringArray(value), { + stripHiddenChars: true, + }) +} + +export function mergeUniqueStringItems( + currentItems: string[], + nextItems: string[], +): string[] { + return normalizeStringListItems([...currentItems, ...nextItems]) +} + +export function serializeStringArrayForSubmit(value: unknown): unknown { + if (!Array.isArray(value)) { + return value + } + return normalizeStringListItems(asStringArray(value)).join("\n") +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index 7569712c4..d253980f8 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -9,6 +9,11 @@ import { getChannelsCatalog, patchAppConfig, } from "@/api/channels" +import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field" +import { + normalizeAllowFromValues, + serializeStringArrayForSubmit, +} from "@/components/channels/channel-array-utils" import { SECRET_FIELD_MAP, buildEditConfig, @@ -23,10 +28,12 @@ import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" import { WecomForm } from "@/components/channels/channel-forms/wecom-form" import { WeixinForm } from "@/components/channels/channel-forms/weixin-form" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { useGateway } from "@/hooks/use-gateway" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" interface ChannelConfigPageProps { @@ -48,6 +55,51 @@ function asBool(value: unknown): boolean { return value === true } +function setRecordValueByPath( + source: Record, + pathSegments: string[], + value: unknown, +): Record { + const [segment, ...rest] = pathSegments + if (!segment) { + return source + } + if (rest.length === 0) { + return { ...source, [segment]: value } + } + return { + ...source, + [segment]: setRecordValueByPath(asRecord(source[segment]), rest, value), + } +} + +function setConfigValueByPath( + source: ChannelConfig, + fieldPath: string, + value: unknown, +): ChannelConfig { + return setRecordValueByPath(source, fieldPath.split("."), value) +} + +function serializeGroupTriggerForSubmit(value: unknown): unknown { + const groupTrigger = asRecord(value) + if (Object.keys(groupTrigger).length === 0) { + return value + } + return { + ...groupTrigger, + prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes), + } +} + +const CHANNEL_COMMON_CONFIG_KEYS = new Set([ + "allow_from", + "group_trigger", + "placeholder", + "reasoning_channel_id", + "typing", +]) + function normalizeConfig( channel: SupportedChannel, rawConfig: ChannelConfig, @@ -67,33 +119,50 @@ function buildSavePayload( editConfig: ChannelConfig, enabled: boolean, ): ChannelConfig { - const payload: ChannelConfig = { enabled } + const payload: ChannelConfig = { enabled, type: channel.config_key } + const settings: ChannelConfig = {} for (const [key, value] of Object.entries(editConfig)) { if (key.startsWith("_")) continue if (key === "enabled") continue + if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) { + if (key === "allow_from") { + payload[key] = serializeStringArrayForSubmit( + normalizeAllowFromValues(value), + ) + } else if (key === "group_trigger") { + payload[key] = serializeGroupTriggerForSubmit(value) + } else { + payload[key] = value + } + continue + } if (isSecretField(key)) continue - payload[key] = value + settings[key] = serializeStringArrayForSubmit(value) } for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { const incoming = asString(editConfig[editKey]) if (incoming !== "") { - payload[secretKey] = incoming + settings[secretKey] = incoming continue } const existing = asString(editConfig[secretKey]).trim() if (existing !== "") { - payload[secretKey] = existing + settings[secretKey] = existing } } if (channel.name === "whatsapp_native") { - payload.use_native = true + settings.use_native = true } if (channel.name === "whatsapp") { - payload.use_native = false + settings.use_native = false + } + + if (Object.keys(settings).length > 0) { + payload.settings = settings } return payload @@ -227,21 +296,36 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [editConfig, setEditConfig] = useState({}) const [configuredSecrets, setConfiguredSecrets] = useState([]) const [enabled, setEnabled] = useState(false) + const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0) + const arrayFieldFlushersRef = useRef(new Map()) + const loadRequestIdRef = useRef(0) + + const resetPageState = useCallback(() => { + arrayFieldFlushersRef.current.clear() + setChannel(null) + setBaseConfig({}) + setEditConfig({}) + setConfiguredSecrets([]) + setEnabled(false) + setFetchError("") + setServerError("") + setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) + }, []) const loadData = useCallback( async (silent = false) => { + const requestId = loadRequestIdRef.current + 1 + loadRequestIdRef.current = requestId if (!silent) setLoading(true) try { const catalog = await getChannelsCatalog() + if (loadRequestIdRef.current !== requestId) return const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { - setChannel(null) - setBaseConfig({}) - setEditConfig({}) - setConfiguredSecrets([]) - setEnabled(false) + resetPageState() setFetchError( t("channels.page.notFound", { name: channelName, @@ -251,6 +335,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { } const channelConfig = await getChannelConfig(channelName) + if (loadRequestIdRef.current !== requestId) return const raw = asRecord(channelConfig.config) const normalized = normalizeConfig(matched, raw) @@ -263,18 +348,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) } catch (e) { + if (loadRequestIdRef.current !== requestId) return setConfiguredSecrets([]) setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { - if (!silent) setLoading(false) + if (!silent && loadRequestIdRef.current === requestId) { + setLoading(false) + } } }, - [channelName, t], + [channelName, resetPageState, t], ) useEffect(() => { + resetPageState() + setLoading(true) loadData() - }, [loadData]) + }, [loadData, resetPageState]) const previousGatewayStatusRef = useRef(gatewayState) useEffect(() => { @@ -285,16 +375,22 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { previousGatewayStatusRef.current = gatewayState }, [gatewayState, loadData]) - const savePayload = useMemo(() => { - if (!channel) return null - return buildSavePayload(channel, editConfig, enabled) - }, [channel, editConfig, enabled]) - const configured = useMemo(() => { if (!channel) return false return isConfigured(channel, editConfig, configuredSecrets) }, [channel, configuredSecrets, editConfig]) + const isDirty = useMemo(() => { + if (loading || !channel || channel.name !== channelName) return false + const basePayload = buildSavePayload( + channel, + buildEditConfig(channel.name, baseConfig), + asBool(baseConfig.enabled), + ) + const currentPayload = buildSavePayload(channel, editConfig, enabled) + return JSON.stringify(basePayload) !== JSON.stringify(currentPayload) + }, [baseConfig, channel, channelName, editConfig, enabled, loading]) + const docsUrl = useMemo(() => { if (!channel) return "" if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" @@ -345,20 +441,52 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }) }, []) + const registerArrayFieldFlusher = useCallback( + (fieldPath: string, flusher: ArrayFieldFlusher | null) => { + if (flusher) { + arrayFieldFlushersRef.current.set(fieldPath, flusher) + return + } + arrayFieldFlushersRef.current.delete(fieldPath) + }, + [], + ) + + const flushPendingArrayFieldDrafts = useCallback( + (sourceConfig: ChannelConfig): ChannelConfig => { + let nextConfig = sourceConfig + for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) { + const flushedValue = flusher() + if (flushedValue === null) { + continue + } + nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue) + } + return nextConfig + }, + [], + ) + const handleReset = () => { if (!channel) return setEditConfig(buildEditConfig(channel.name, baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) } const handleSave = async () => { - if (!channel || !savePayload) return + if (!channel) return + + const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig) + if (preparedEditConfig !== editConfig) { + setEditConfig(preparedEditConfig) + } const missingRequiredFields = requiredKeys.filter((key) => isMissingRequiredValue( - getFieldValueForValidation(editConfig, configuredSecrets, key), + getFieldValueForValidation(preparedEditConfig, configuredSecrets, key), ), ) if (missingRequiredFields.length > 0) { @@ -376,12 +504,20 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) try { + const savePayload = buildSavePayload(channel, preparedEditConfig, enabled) await patchAppConfig({ - channels: { + channel_list: { [channel.config_key]: savePayload, }, }) await loadData() + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("channels.page.saveSuccess"), + channelDisplayName, + gateway?.restartRequired === true, + ) } catch (e) { const message = e instanceof Error ? e.message : t("channels.page.saveError") @@ -445,6 +581,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "discord": @@ -454,6 +592,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "slack": @@ -463,6 +603,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "feishu": @@ -472,6 +614,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "weixin": @@ -481,6 +625,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} isEdit={isEdit} onBindSuccess={() => void handleWeixinBindSuccess()} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "wecom": @@ -501,6 +647,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={[...hiddenKeys, "bot_id"]} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) @@ -513,6 +661,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={hiddenKeys} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) } @@ -563,11 +713,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {

{serverError}

)} + {isDirty && ( + + )} +
- -
diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index f72e1c5c7..d2a98d325 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface DiscordFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asBool(value: unknown): boolean { return value === true } @@ -38,6 +46,8 @@ export function DiscordForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) @@ -78,24 +88,17 @@ export function DiscordForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { @@ -21,9 +35,11 @@ function asBool(value: unknown): boolean { return typeof value === "boolean" ? value : false } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} } export function FeishuForm({ @@ -31,8 +47,11 @@ export function FeishuForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: FeishuFormProps) { const { t } = useTranslation() + const groupTriggerConfig = asRecord(config.group_trigger) return (
@@ -104,24 +123,17 @@ export function FeishuForm({ /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
+ + + +
+ { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> +
+ + onChange("random_reaction_emoji", value)} + placeholder={t("channels.field.randomReactionEmojiPlaceholder")} + parser={parseConservativeStringListInput} + fieldPath="random_reaction_emoji" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> +
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index 526a3c808..c8ee3f69f 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder, isSecretField, @@ -16,6 +24,11 @@ interface GenericFormProps { hiddenKeys?: string[] requiredKeys?: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } // Fields to skip in the generic form (handled by enabled toggle or internal). @@ -48,11 +61,6 @@ function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -71,6 +79,8 @@ export function GenericForm({ hiddenKeys = [], requiredKeys = [], fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: GenericFormProps) { const { t } = useTranslation() const hiddenFieldSet = new Set(hiddenKeys) @@ -187,26 +197,18 @@ export function GenericForm({ if (Array.isArray(value)) { return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - + value={asStringArray(value)} + onChange={(nextValue) => onChange(key, nextValue)} + fieldPath={key} + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> ) } @@ -281,46 +283,31 @@ export function GenericForm({ {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_origins !== undefined && !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - + value={asStringArray(config.allow_origins)} + onChange={(value) => onChange("allow_origins", value)} + placeholder={t("channels.field.allowOriginsPlaceholder")} + fieldPath="allow_origins" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_token_query !== undefined && @@ -356,26 +343,21 @@ export function GenericForm({ />
- - - onChange("group_trigger", { - ...groupTriggerConfig, - prefixes: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - + value={asStringArray(groupTriggerConfig.prefixes)} + onChange={(value) => + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: value, + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + fieldPath="group_trigger.prefixes" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 14ffa0913..b8184e8bc 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,32 +1,41 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function SlackForm({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: SlackFormProps) { const { t } = useTranslation() @@ -72,24 +81,17 @@ export function SlackForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 696da245d..f9c7c778a 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface TelegramFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -38,6 +46,8 @@ export function TelegramForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) @@ -91,24 +101,17 @@ export function TelegramForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void isEdit: boolean onBindSuccess?: () => void + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function WeixinForm({ config, onChange, isEdit, onBindSuccess, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: WeixinFormProps) { const { t } = useTranslation() @@ -321,24 +331,17 @@ export function WeixinForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> 0 + const hasToolCalls = toolCalls.length > 0 + const imageAttachments = attachments.filter( + (attachment) => attachment.type === "image", + ) + const fileAttachments = attachments.filter( + (attachment) => attachment.type !== "image", + ) + const [isExpanded, setIsExpanded] = useState(true) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" @@ -34,67 +63,212 @@ export function AssistantMessage({ }) } + const collapsedLabel = isThought + ? t("chat.reasoningLabel") + : t("chat.toolCallsLabel") + return (
-
-
- PicoClaw - {isThought && ( - - - {t("chat.reasoningLabel")} - - )} - {formattedTimestamp && ( - <> - - {formattedTimestamp} - - )} + {!isCollapsedBlock && ( +
+
+ PicoClaw + {formattedTimestamp && ( + <> + + {formattedTimestamp} + + )} +
-
+ )} -
+ {(hasText || isCollapsedBlock || hasToolCalls) && (
- - {content} - + {isCollapsedBlock && ( +
setIsExpanded(!isExpanded)} + > +
+ {isThought ? ( + + ) : ( + + )} + {collapsedLabel} +
+ +
+ )} + {(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && ( +
+ {toolCalls.map((toolCall, index) => { + const explanation = + toolCall.extraContent?.toolFeedbackExplanation?.trim() ?? "" + const toolName = toolCall.function?.name?.trim() ?? "" + const toolArguments = toolCall.function?.arguments?.trim() ?? "" + const hasFunctionSummary = toolName || toolArguments + + if (!explanation && !hasFunctionSummary) { + return null + } + + return ( +
0 && "border-border/20 border-t pt-3", + )} + > + {explanation && ( +
+
+ {t("chat.toolCallExplanationLabel")} +
+
+ + {explanation} + +
+
+ )} + + {hasFunctionSummary && ( +
+
+ {t("chat.toolCallFunctionLabel")} +
+
+ {toolName && ( +
+ {toolName} +
+ )} + {toolArguments && ( +
+                              {toolArguments}
+                            
+ )} +
+
+ )} +
+ ) + })} +
+ )} + {(!isCollapsedBlock || isExpanded) && !isToolCalls && hasText && ( +
+ + {content} + +
+ )} + + {!isCollapsedBlock && hasText && ( + + )}
- -
+ )} + + {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + {attachment.filename +
+ + ))} +
+ )} + + {fileAttachments.length > 0 && ( + + )}
) } diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index 58612d846..b3354cc33 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -3,9 +3,15 @@ import type { KeyboardEvent } from "react" import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" +import { ContextUsageRing } from "@/components/chat/context-usage-ring" import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import type { ChatAttachment } from "@/store/chat" +import type { ChatAttachment, ContextUsage } from "@/store/chat" export type ChatInputDisabledReason = | "gatewayUnknown" @@ -26,8 +32,10 @@ interface ChatComposerProps { onAddImages: () => void onRemoveAttachment: (index: number) => void onSend: () => void + onContextDetail?: () => void inputDisabledReason: ChatInputDisabledReason | null canSend: boolean + contextUsage?: ContextUsage } export function ChatComposer({ @@ -37,8 +45,10 @@ export function ChatComposer({ onAddImages, onRemoveAttachment, onSend, + onContextDetail, inputDisabledReason, canSend, + contextUsage, }: ChatComposerProps) { const { t } = useTranslation() const canInput = inputDisabledReason === null @@ -57,8 +67,8 @@ export function ChatComposer({ } return ( -
-
+
+
{attachments.length > 0 && (
{attachments.map((attachment, index) => ( @@ -93,17 +103,12 @@ export function ChatComposer({ disabled={!canInput} title={disabledMessage || undefined} className={cn( - "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", + "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", !canInput && "cursor-not-allowed", )} minRows={1} maxRows={8} /> - {!canInput && disabledMessage && ( -
- {disabledMessage} -
- )}
@@ -121,17 +126,35 @@ export function ChatComposer({
- {canInput ? ( - - ) : null} +
+ {contextUsage && ( + + )} + {canInput ? ( + + + + + + + + {t("chat.sendHint")} + + + ) : null} +
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 4129d812a..3ad811dae 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -1,4 +1,5 @@ import { IconPlus } from "@tabler/icons-react" +import { useAtom } from "jotai" import { type ChangeEvent, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -15,12 +16,14 @@ import { TypingIndicator } from "@/components/chat/typing-indicator" import { UserMessage } from "@/components/chat/user-message" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" import { useChatModels } from "@/hooks/use-chat-models" import { useGateway } from "@/hooks/use-gateway" import { usePicoChat } from "@/hooks/use-pico-chat" import { useSessionHistory } from "@/hooks/use-session-history" import type { ConnectionState } from "@/store/chat" import type { ChatAttachment } from "@/store/chat" +import { showAssistantDetailsAtom } from "@/store/chat" import type { GatewayState } from "@/store/gateway" const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 @@ -109,12 +112,16 @@ export function ChatPage() { const [hasScrolled, setHasScrolled] = useState(false) const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) + const [showAssistantDetails, setShowAssistantDetails] = useAtom( + showAssistantDetailsAtom, + ) const { messages, connectionState, isTyping, activeSessionId, + contextUsage, sendMessage, switchSession, newChat, @@ -153,7 +160,7 @@ export function ChatPage() { }) const syncScrollState = (element: HTMLDivElement) => { - const { scrollTop, scrollHeight, clientHeight } = element + const { clientHeight, scrollHeight, scrollTop } = element setHasScrolled(scrollTop > 0) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) } @@ -264,6 +271,18 @@ export function ChatPage() { ) } > +
+ + {t("chat.showAssistantDetails")} + + +
+
) diff --git a/web/frontend/src/components/chat/context-usage-ring.tsx b/web/frontend/src/components/chat/context-usage-ring.tsx new file mode 100644 index 000000000..4a32e617b --- /dev/null +++ b/web/frontend/src/components/chat/context-usage-ring.tsx @@ -0,0 +1,161 @@ +import { IconArrowRight } from "@tabler/icons-react" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ContextUsage } from "@/store/chat" + +interface ContextUsageRingProps { + usage: ContextUsage + onDetailClick?: () => void +} + +function formatTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +export function ContextUsageRing({ + usage, + onDetailClick, +}: ContextUsageRingProps) { + const { t } = useTranslation() + const [intent, setIntent] = useState(false) // user wants open + const [visible, setVisible] = useState(false) // DOM mounted + const [animated, setAnimated] = useState(false) // CSS target state + const [cooldown, setCooldown] = useState(false) + const containerRef = useRef(null) + const timerRef = useRef>(null) + const hoverIntent = useRef>(null) + const closeTimer = useRef>(null) + + useEffect(() => { + if (intent) { + // Mount first, animate in on next frame + if (closeTimer.current) clearTimeout(closeTimer.current) + setVisible(true) + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimated(true)) + }) + } else if (visible) { + // Animate out, then unmount + setAnimated(false) + closeTimer.current = setTimeout(() => setVisible(false), 150) + } + }, [intent, visible]) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + if (hoverIntent.current) clearTimeout(hoverIntent.current) + if (closeTimer.current) clearTimeout(closeTimer.current) + } + }, []) + + const percent = Math.min(usage.used_percent, 100) + const radius = 8 + const circumference = 2 * Math.PI * radius + const offset = circumference - (percent / 100) * circumference + const barPercent = Math.min(percent, 100) + + const handleDetail = () => { + if (cooldown || !onDetailClick) return + setCooldown(true) + onDetailClick() + setIntent(false) + timerRef.current = setTimeout(() => setCooldown(false), 1000) + } + + // Desktop: hover to open, mouse leave to close (with small delay) + const handleMouseEnter = () => { + if (hoverIntent.current) clearTimeout(hoverIntent.current) + setIntent(true) + } + + const handleMouseLeave = () => { + hoverIntent.current = setTimeout(() => setIntent(false), 150) + } + + // Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter) + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault() + setIntent((v) => !v) + } + + return ( +
+ + + {visible && ( +
+
+ +
+ + {t("chat.contextTitle")} + + + {formatTokens(usage.used_tokens)} /{" "} + {formatTokens(usage.compress_at_tokens)} + +
+
+
+
+ + +
+ )} +
+ ) +} diff --git a/web/frontend/src/components/chat/typing-indicator.tsx b/web/frontend/src/components/chat/typing-indicator.tsx index 98580963d..df138553c 100644 --- a/web/frontend/src/components/chat/typing-indicator.tsx +++ b/web/frontend/src/components/chat/typing-indicator.tsx @@ -21,10 +21,7 @@ export function TypingIndicator() { return (
-
- PicoClaw -
-
+
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index 96119a534..8bfdf24c9 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" interface UserMessageProps { @@ -7,6 +8,7 @@ interface UserMessageProps { export function UserMessage({ content, attachments = [] }: UserMessageProps) { const hasText = content.trim().length > 0 + const isCommand = content.trim().startsWith("/") const imageAttachments = attachments.filter( (attachment) => attachment.type === "image", ) @@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) { )} {hasText && ( -
- {content} +
+ {isCommand ? ( +
+ + ❯ + + {content} +
+ ) : ( + content + )}
)}
diff --git a/web/frontend/src/components/config-change-notice.tsx b/web/frontend/src/components/config-change-notice.tsx new file mode 100644 index 000000000..27e5eed7d --- /dev/null +++ b/web/frontend/src/components/config-change-notice.tsx @@ -0,0 +1,48 @@ +import { + IconAlertCircle, + IconDeviceFloppy, + IconRefresh, +} from "@tabler/icons-react" + +import { cn } from "@/lib/utils" + +interface ConfigChangeNoticeProps { + kind: "save" | "restart" + title: string + description?: string + className?: string +} + +export function ConfigChangeNotice({ + kind, + title, + description, + className, +}: ConfigChangeNoticeProps) { + const Icon = + kind === "restart" + ? IconRefresh + : kind === "save" + ? IconDeviceFloppy + : IconAlertCircle + + return ( +
+ +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 0ad2031f7..0b5665640 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner" import { patchAppConfig } from "@/api/channels" import { launcherFetch } from "@/api/http" +import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { getAutoStartStatus, getLauncherConfig, @@ -14,6 +15,7 @@ import { setAutoStartEnabled as updateAutoStartEnabled, setLauncherConfig as updateLauncherConfig, } from "@/api/system" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { AgentDefaultsSection, CronSection, @@ -35,6 +37,7 @@ import { import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" export function ConfigPage() { @@ -94,7 +97,8 @@ export function ConfigPage() { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), - launcherToken: launcherConfig.launcher_token ?? "", + dashboardPassword: "", + dashboardPasswordConfirm: "", } setLauncherForm(parsed) setLauncherBaseline(parsed) @@ -107,8 +111,14 @@ export function ConfigPage() { }, [autoStartStatus]) const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) - const launcherDirty = - JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline) + const launcherSettingsDirty = + launcherForm.port !== launcherBaseline.port || + launcherForm.publicAccess !== launcherBaseline.publicAccess || + launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText + const launcherPasswordDirty = + launcherForm.dashboardPassword.trim() !== "" || + launcherForm.dashboardPasswordConfirm.trim() !== "" + const launcherDirty = launcherSettingsDirty || launcherPasswordDirty const autoStartDirty = autoStartEnabled !== autoStartBaseline const isDirty = configDirty || launcherDirty || autoStartDirty @@ -143,6 +153,19 @@ export function ConfigPage() { const handleSave = async () => { try { setSaving(true) + const password = launcherForm.dashboardPassword.trim() + const confirm = launcherForm.dashboardPasswordConfirm.trim() + if (launcherPasswordDirty) { + if (!password) { + throw new Error(t("pages.config.dashboard_password_required")) + } + if (password !== confirm) { + throw new Error(t("pages.config.dashboard_password_mismatch")) + } + if (Array.from(password).length < 8) { + throw new Error(t("pages.config.dashboard_password_min_length")) + } + } if (configDirty) { const workspace = form.workspace.trim() @@ -223,6 +246,7 @@ export function ConfigPage() { tool_feedback: { enabled: form.toolFeedbackEnabled, max_args_length: toolFeedbackMaxArgsLength, + separate_messages: form.toolFeedbackSeparateMessages, }, max_tokens: maxTokens, context_window: contextWindow, @@ -255,7 +279,8 @@ export function ConfigPage() { queryClient.invalidateQueries({ queryKey: ["config"] }) } - if (launcherDirty) { + let savedLauncherForm: LauncherForm | null = null + if (launcherSettingsDirty) { const port = parseIntField(launcherForm.port, "Service port", { min: 1, max: 65535, @@ -265,7 +290,6 @@ export function ConfigPage() { port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, - launcher_token: launcherForm.launcherToken.trim(), }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), @@ -273,8 +297,10 @@ export function ConfigPage() { allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), - launcherToken: savedLauncherConfig.launcher_token ?? "", + dashboardPassword: "", + dashboardPasswordConfirm: "", } + savedLauncherForm = parsedLauncher setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) queryClient.setQueryData( @@ -283,6 +309,23 @@ export function ConfigPage() { ) } + if (launcherPasswordDirty) { + const result = await postLauncherDashboardSetup(password, confirm) + if (!result.ok) { + throw new Error(result.error) + } + + const clearedLauncherForm = savedLauncherForm ?? { + ...launcherForm, + dashboardPassword: "", + dashboardPasswordConfirm: "", + } + setLauncherForm(clearedLauncherForm) + if (savedLauncherForm) { + setLauncherBaseline(savedLauncherForm) + } + } + if (autoStartDirty) { if (!autoStartSupported) { throw new Error(t("pages.config.autostart_unsupported")) @@ -293,8 +336,13 @@ export function ConfigPage() { queryClient.setQueryData(["system", "autostart"], status) } - toast.success(t("pages.config.save_success")) - void refreshGatewayState({ force: true }) + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) } catch (err) { toast.error( err instanceof Error ? err.message : t("pages.config.save_error"), @@ -304,6 +352,22 @@ export function ConfigPage() { } } + const actionButtons = ( +
+ + +
+ ) + return (
) : (
- {isDirty && ( -
- {t("pages.config.unsaved_changes")} -
- )} - -
- - -
+ {!isDirty && actionButtons}
)}
+ {isDirty && ( +
+
+
+ +
+ {actionButtons} +
+
+ )}
) } diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 21f89d7c1..fa6b3a079 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -113,6 +113,18 @@ export function AgentDefaultsSection({ } /> + {form.toolFeedbackEnabled && ( + + onFieldChange("toolFeedbackSeparateMessages", checked) + } + /> + )} + {form.toolFeedbackEnabled && ( onFieldChange("launcherToken", e.target.value)} + autoComplete="new-password" + placeholder={t("pages.config.dashboard_password_placeholder")} + onChange={(e) => + onFieldChange("dashboardPassword", e.target.value) + } /> + {launcherForm.dashboardPassword.trim() !== "" && ( + + + onFieldChange("dashboardPasswordConfirm", e.target.value) + } + /> + + )} + { - toast.success(t("pages.config.save_success")) try { const savedConfig = JSON.parse(submittedConfig) setLastSavedConfig(savedConfig) @@ -58,7 +59,14 @@ export function RawConfigPage() { } catch { queryClient.invalidateQueries({ queryKey: ["config"] }) } - void refreshGatewayState({ force: true }) + void refreshGatewayState({ force: true }).then((gateway) => { + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) + }) }, onError: () => { toast.error(t("pages.config.save_error")) @@ -141,9 +149,12 @@ export function RawConfigPage() { ) : (
{isDirty && ( -
- {t("pages.config.unsaved_changes")} -
+ )}