Merge branch 'sipeed:main' into main

This commit is contained in:
Orange Pi Vietnam 2026-04-26 16:47:49 +07:00 committed by GitHub
commit ed7c570917
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 7574 additions and 652 deletions

3
.gitattributes vendored Normal file
View file

@ -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

View file

@ -74,7 +74,10 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v4 uses: docker/login-action@v4
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -86,6 +89,10 @@ jobs:
- name: Create local tag for GoReleaser - name: Create local tag for GoReleaser
run: git tag "${{ steps.version.outputs.version }}" 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 - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7 uses: goreleaser/goreleaser-action@v7
with: with:
@ -94,7 +101,7 @@ jobs:
args: release --clean args: release --clean
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} REPO_OWNER: ${{ steps.repo.outputs.owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }} GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
@ -144,3 +151,154 @@ jobs:
--prerelease \ --prerelease \
--latest=false \ --latest=false \
"${ASSETS[@]}" "${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)"

View file

@ -80,7 +80,10 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v4 uses: docker/login-action@v4
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with: with:
registry: docker.io registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -89,6 +92,10 @@ jobs:
- name: Install zip - name: Install zip
run: sudo apt-get install -y 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 - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7 uses: goreleaser/goreleaser-action@v7
with: with:
@ -97,7 +104,7 @@ jobs:
args: release --clean args: release --clean
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} REPO_OWNER: ${{ steps.repo.outputs.owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }} GOVERSION: ${{ steps.setup-go.outputs.go-version }}
INCLUDE_ANDROID_BUNDLE: "true" INCLUDE_ANDROID_BUNDLE: "true"
@ -116,9 +123,149 @@ jobs:
--draft=${{ inputs.draft }} \ --draft=${{ inputs.draft }} \
--prerelease=${{ inputs.prerelease }} --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: upload-tos:
name: Upload to TOS name: Upload to TOS
needs: release needs: [release, patch-macos-archives]
if: ${{ inputs.upload_tos }} if: ${{ inputs.upload_tos }}
uses: ./.github/workflows/upload-tos.yml uses: ./.github/workflows/upload-tos.yml
with: with:

4
.gitignore vendored
View file

@ -55,6 +55,10 @@ dist/
# Windows Application Icon/Resource # Windows Application Icon/Resource
*.syso *.syso
.cache/
web/frontend/.pnpm-store/
_tmp_*
web/frontend/_tmp_*
# Test telegram integration # Test telegram integration
cmd/telegram/ cmd/telegram/

View file

@ -151,8 +151,8 @@ dockers_v2:
ids: ids:
- picoclaw - picoclaw
images: images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
tags: tags:
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
@ -168,8 +168,8 @@ dockers_v2:
- picoclaw-launcher - picoclaw-launcher
- picoclaw-launcher-tui - picoclaw-launcher-tui
images: images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
tags: tags:
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
@ -224,7 +224,7 @@ nfpms:
{{- else if eq .Arch "arm" }}armv{{ .Arm }} {{- else if eq .Arch "arm" }}armv{{ .Arm }}
{{- else }}{{ .Arch }}{{ end }} {{- else }}{{ .Arch }}{{ end }}
vendor: picoclaw vendor: picoclaw
homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw homepage: https://github.com/{{ .Env.REPO_OWNER }}/picoclaw
maintainer: picoclaw contributors maintainer: picoclaw contributors
description: picoclaw - a tool for managing and running tasks description: picoclaw - a tool for managing and running tasks
license: MIT license: MIT

View file

@ -7,19 +7,43 @@ CMD_DIR=cmd/$(BINARY_NAME)
MAIN_GO=$(CMD_DIR)/main.go MAIN_GO=$(CMD_DIR)/main.go
EXT= EXT=
ifeq ($(OS),Windows_NT)
POWERSHELL=powershell -NoProfile -Command
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
endif
# Version # Version
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") ifeq ($(OS),Windows_NT)
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
BUILD_TIME=$(shell date +%FT%T%z) GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
GO_VERSION=$(shell $(GO) version | awk '{print $$3}') 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 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 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 variables
GO?=CGO_ENABLED=0 go GO?=go
WEB_GO?=$(GO) WEB_GO?=$(GO)
CGO_ENABLED?=0
GO_BUILD_TAGS?=goolm,stdjson GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS) 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:=, comma:=,
empty:= empty:=
space:=$(empty) $(empty) space:=$(empty) $(empty)
@ -73,8 +97,21 @@ BUILTIN_SKILLS_DIR=$(CURDIR)/skills
LNCMD=ln -sf LNCMD=ln -sf
# OS detection # OS detection
UNAME_S?=$(shell uname -s) ifeq ($(OS),Windows_NT)
UNAME_M?=$(shell uname -m) 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 # Platform-specific settings
ifeq ($(UNAME_S),Linux) ifeq ($(UNAME_S),Linux)
@ -122,6 +159,18 @@ else
endif 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) BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
# Default target # Default target
@ -130,21 +179,37 @@ all: build
## generate: Run generate ## generate: Run generate
generate: generate:
@echo "Run 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 @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
endif
@$(GO) generate ./... @$(GO) generate ./...
@echo "Run generate complete" @echo "Run generate complete"
## build: Build the picoclaw binary for current platform ## build: Build the picoclaw binary for current platform
build: generate build: generate
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..." @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) @mkdir -p $(BUILD_DIR)
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR) @GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)$(EXT)" @echo "Build complete: $(BINARY_PATH)$(EXT)"
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(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: Build the picoclaw-launcher (web console) binary
build-launcher: build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @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) @mkdir -p $(BUILD_DIR)
@GOARCH=${ARCH} $(MAKE) -C web build \ @GOARCH=${ARCH} $(MAKE) -C web build \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
@ -152,6 +217,7 @@ build-launcher:
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
LDFLAGS='$(LDFLAGS)' LDFLAGS='$(LDFLAGS)'
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT) @$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
endif
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)" @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
build-launcher-frontend: build-launcher-frontend:
@ -160,10 +226,16 @@ build-launcher-frontend:
## build-launcher-tui: Build the picoclaw-launcher TUI binary ## build-launcher-tui: Build the picoclaw-launcher TUI binary
build-launcher-tui: build-launcher-tui:
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..." @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) @mkdir -p $(BUILD_DIR)
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui @$(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 @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: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate build-whatsapp-native: generate
@ -290,7 +362,11 @@ uninstall-all:
## clean: Remove build artifacts ## clean: Remove build artifacts
clean: clean:
@echo "Cleaning build artifacts..." @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) @rm -rf $(BUILD_DIR)
endif
@echo "Clean complete" @echo "Clean complete"
## vet: Run go vet for static analysis ## vet: Run go vet for static analysis

View file

@ -571,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/reference/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).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network ## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
@ -591,6 +604,11 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message
| `picoclaw status` | Show status | | `picoclaw status` | Show status |
| `picoclaw version` | Show version info | | `picoclaw version` | Show version info |
| `picoclaw model` | View or switch the default model | | `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 list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job | | `picoclaw cron add ...` | Add a scheduled job |
| `picoclaw cron disable` | Disable a scheduled job | | `picoclaw cron disable` | Disable a scheduled job |
@ -619,6 +637,7 @@ For detailed guides beyond this README:
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes | | [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 | | [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox | | [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 | | [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 | | [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 | | [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 360 KiB

View file

@ -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 + "…"
}

View file

@ -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] <name> <command-or-url> [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] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [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] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
},
}
}

View file

@ -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 "<missing url>"
}
return server.URL
}
parts := append([]string{server.Command}, server.Args...)
rendered := strings.TrimSpace(strings.Join(parts, " "))
if rendered == "" {
return "<missing command>"
}
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
}

View file

@ -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
}

View file

@ -0,0 +1,39 @@
package mcp
import (
"fmt"
"github.com/spf13/cobra"
)
func newRemoveCommand() *cobra.Command {
return &cobra.Command{
Use: "remove <name>",
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
},
}
}

View file

@ -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 <name>",
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
}

View file

@ -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 <name>",
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
}

View file

@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
//go:generate cp -r ../../../../workspace . //go:generate go run ../../../../scripts/copydir.go "${DOLLAR}{codespace}/workspace" ./workspace
//go:embed workspace //go:embed workspace
var embeddedFiles embed.FS var embeddedFiles embed.FS

View file

@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "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/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
@ -87,6 +88,7 @@ picoclaw --no-color status`,
gateway.NewGatewayCommand(), gateway.NewGatewayCommand(),
status.NewStatusCommand(), status.NewStatusCommand(),
cron.NewCronCommand(), cron.NewCronCommand(),
mcp.NewMCPCommand(),
migrate.NewMigrateCommand(), migrate.NewMigrateCommand(),
skills.NewSkillsCommand(), skills.NewSkillsCommand(),
model.NewModelCommand(), model.NewModelCommand(),

View file

@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"auth", "auth",
"cron", "cron",
"gateway", "gateway",
"mcp",
"migrate", "migrate",
"model", "model",
"onboard", "onboard",

View file

@ -13,7 +13,8 @@
"split_on_marker": false, "split_on_marker": false,
"tool_feedback": { "tool_feedback": {
"enabled": false, "enabled": false,
"max_args_length": 300 "max_args_length": 300,
"separate_messages": false
} }
} }
}, },

View file

@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js
exit 0 exit 0
fi 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 "$@" exec picoclaw gateway "$@"

View file

@ -65,7 +65,8 @@ Debug logs are server-side only. If you want the agent to send a visible notific
"defaults": { "defaults": {
"tool_feedback": { "tool_feedback": {
"enabled": true, "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 | | Field | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `enabled` | bool | `false` | Send a chat notification for each tool call | | `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 | | `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
### Environment variables ### Environment variables

View file

@ -554,7 +554,20 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet
} }
``` ```
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). Puoi gestire i casi MCP più comuni direttamente dalla CLI senza modificare a mano il JSON:
```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).
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent ## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
@ -574,6 +587,11 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
| `picoclaw status` | Mostra lo stato | | `picoclaw status` | Mostra lo stato |
| `picoclaw version` | Mostra le info sulla versione | | `picoclaw version` | Mostra le info sulla versione |
| `picoclaw model` | Visualizza o cambia il modello predefinito | | `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 list` | Elenca tutti i job pianificati |
| `picoclaw cron add ...` | Aggiunge un job pianificato | | `picoclaw cron add ...` | Aggiunge un job pianificato |
| `picoclaw cron disable` | Disabilita un job pianificato | | `picoclaw cron disable` | Disabilita un job pianificato |
@ -600,6 +618,7 @@ Per guide dettagliate oltre questo README:
| [Docker & Avvio Rapido](../guides/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | | [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 | | [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 | | [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 | | [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 | | [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 | | [Hooks](../architecture/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |

View file

@ -3,6 +3,7 @@
Reference docs for precise configuration, runtime behavior, and tool semantics. Reference docs for precise configuration, runtime behavior, and tool semantics.
- [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills. - [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. - [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. - [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. - [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers.

361
docs/reference/mcp-cli.md Normal file
View file

@ -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 <name> [flags] <command-or-url> [args...]` | Add or update an MCP server entry |
| `picoclaw mcp remove <name>` | Remove a server entry from config |
| `picoclaw mcp list` | List configured MCP servers |
| `picoclaw mcp show <name>` | Show full details and tools for one server |
| `picoclaw mcp test <name>` | Try connecting to one configured server |
| `picoclaw mcp edit` | Open `config.json` in `$EDITOR` |
## `picoclaw mcp add`
Syntax:
```bash
picoclaw mcp add <name> [flags] <command-or-url> [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] <name> <command-or-url> [args...]
picoclaw mcp add [flags] <name> -- <command> [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 `-- <command> [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 `<name>` and `<command-or-url>`
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`:
- `<command-or-url>` 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
- `-- <command> [args...]` is supported and recommended for unambiguous parsing
For `http` / `sse`:
- `<command-or-url>` 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 `<name>` 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 <name>
```
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 <name>`
## `picoclaw mcp show`
Syntax:
```bash
picoclaw mcp show <name>
picoclaw mcp show <name> --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 <string> 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 <name>
```
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 <name>`.
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 <name>` 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

View file

@ -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. 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 <name>` — show full details and the tool list for one server
- `picoclaw mcp test <name>` — connectivity check for one server
- `picoclaw mcp remove <name>` — 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) ### Tool Discovery (Lazy Loading)
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window

36
go.mod
View file

@ -9,9 +9,9 @@ require (
github.com/adhocore/gronx v1.19.6 github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/atc0005/go-teams-notify/v2 v2.14.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 v1.41.6
github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/config v1.32.16
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0 github.com/caarlos0/env/v11 v11.4.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
@ -23,7 +23,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.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/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0 github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/modelcontextprotocol/go-sdk v1.5.0
@ -34,7 +34,7 @@ require (
github.com/pion/rtp v1.10.1 github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v3 v3.3.6 github.com/pion/webrtc/v3 v3.3.6
github.com/rivo/tview v0.42.0 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/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
@ -55,19 +55,19 @@ require (
require ( require (
aead.dev/minisign v0.2.0 // indirect aead.dev/minisign v0.2.0 // indirect
filippo.io/edwards25519 v1.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/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // 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.21 // 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.21 // 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.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // 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.7 // 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.21 // 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.9 // 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.15 // 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.19 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/aws/smithy-go v1.24.2 // indirect github.com/aws/smithy-go v1.25.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect github.com/beeper/argo-go v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect

74
go.sum
View file

@ -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/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 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= 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.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= 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.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= 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.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= 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.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= 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.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= 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.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= 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.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= 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.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= 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.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= 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.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= 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.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= 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.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= 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/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= 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.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4= 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.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k= 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.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= 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.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= 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.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= 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.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= 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.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= 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.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= 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.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= 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.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= 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.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= 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.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= 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.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= 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.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
@ -138,8 +138,6 @@ 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.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 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/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 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8=
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 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.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -185,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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
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/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@ -243,8 +241,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.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 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 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.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= 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/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 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=

View file

@ -135,6 +135,25 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
serverCfg := al.cfg.Tools.MCP.Servers[serverName] serverCfg := al.cfg.Tools.MCP.Servers[serverName]
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg) 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 _, tool := range conn.Tools {
for _, agentID := range agentIDs { for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID) agent, ok := al.registry.GetAgent(agentID)

View file

@ -1720,6 +1720,38 @@ func (m *messageToolProvider) GetDefaultModel() string {
return "message-tool-model" 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 { type artifactThenSendProvider struct {
calls int calls int
} }
@ -1866,6 +1898,28 @@ func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.
} }
} }
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) { func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t *testing.T) {
response := &providers.LLMResponse{ response := &providers.LLMResponse{
ToolCalls: []providers.ToolCall{{ ToolCalls: []providers.ToolCall{{
@ -1965,6 +2019,17 @@ func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testi
} }
} }
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 { type picoInterleavedContentProvider struct {
calls int calls int
} }
@ -3940,6 +4005,12 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
if !strings.Contains(outbound.Content, "check tool feedback") { if !strings.Contains(outbound.Content, "check tool feedback") {
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) 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, heartbeatFile) {
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
}
if strings.Contains(outbound.Content, "Previous turn explanation") { if strings.Contains(outbound.Content, "Previous turn explanation") {
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
} }
@ -3957,6 +4028,182 @@ 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) { func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt") heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt")
@ -4012,6 +4259,12 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T)
if !strings.Contains(outbound.Content, "check reasoning fallback") { if !strings.Contains(outbound.Content, "check reasoning fallback") {
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) 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, heartbeatFile) {
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
}
if strings.Contains(outbound.Content, "Read README.md first") { if strings.Contains(outbound.Content, "Read README.md first") {
t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content)
} }
@ -4310,7 +4563,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi
} }
} }
if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text" { if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text\n```json\n{\n \"value\": \"x\"\n}\n```" {
t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0]) t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0])
} }
if outputs[1] != "final model text" { if outputs[1] != "final model text" {

View file

@ -4,7 +4,9 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"maps"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@ -170,6 +172,18 @@ func toolFeedbackExplanationFromMessages(messages []providers.Message) string {
return "" 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 { func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool {
if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback { if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback {
return false return false
@ -465,17 +479,28 @@ func sideQuestionResponseContent(response *providers.LLMResponse) string {
if response == nil { if response == nil {
return "" return ""
} }
if response.Content != "" { if strings.TrimSpace(response.Content) != "" {
return response.Content return response.Content
} }
return response.ReasoningContent 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 { func shallowCloneLLMOptions(opts map[string]any) map[string]any {
clone := make(map[string]any, len(opts)) clone := make(map[string]any, len(opts))
for k, v := range opts { maps.Copy(clone, opts)
clone[k] = v
}
return clone return clone
} }

View file

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
@ -21,12 +22,11 @@ import (
) )
type ContextBuilder struct { type ContextBuilder struct {
workspace string workspace string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore memory *MemoryStore
toolDiscoveryBM25 bool splitOnMarker bool
toolDiscoveryRegex bool promptRegistry *PromptRegistry
splitOnMarker bool
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
@ -48,8 +48,16 @@ type ContextBuilder struct {
} }
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder { func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
cb.toolDiscoveryBM25 = useBM25 if useBM25 || useRegex {
cb.toolDiscoveryRegex = 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 return cb
} }
@ -73,15 +81,38 @@ func NewContextBuilder(workspace string) *ContextBuilder {
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
return &ContextBuilder{ return &ContextBuilder{
workspace: workspace, workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace), 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 { func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
toolDiscovery := cb.getDiscoveryRule()
version := config.FormatVersion() version := config.FormatVersion()
return fmt.Sprintf( return fmt.Sprintf(
@ -103,22 +134,20 @@ Your workspace is at: %s
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md 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. 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)
%s`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
} }
func (cb *ContextBuilder) getDiscoveryRule() string { func formatToolDiscoveryRule(useBM25, useRegex bool) string {
if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex { if !useBM25 && !useRegex {
return "" return ""
} }
var toolNames []string var toolNames []string
if cb.toolDiscoveryBM25 { if useBM25 {
toolNames = append(toolNames, `"tool_search_tool_bm25"`) toolNames = append(toolNames, `"tool_search_tool_bm25"`)
} }
if cb.toolDiscoveryRegex { if useRegex {
toolNames = append(toolNames, `"tool_search_tool_regex"`) toolNames = append(toolNames, `"tool_search_tool_regex"`)
} }
@ -129,43 +158,103 @@ func (cb *ContextBuilder) getDiscoveryRule() string {
} }
func (cb *ContextBuilder) BuildSystemPrompt() 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 // 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 // Bootstrap files
bootstrapContent := cb.LoadBootstrapFiles() bootstrapContent := cb.LoadBootstrapFiles()
if bootstrapContent != "" { 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 // Skills - show summary, AI can read full content with read_file tool
skillsSummary := cb.skillsLoader.BuildSkillsSummary() skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" { 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. 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 // Memory context
memoryContext := cb.memory.GetMemoryContext() memoryContext := cb.memory.GetMemoryContext()
if memoryContext != "" { 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) // Multi-Message Sending (if enabled)
if cb.splitOnMarker { 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 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 stack.Seal()
return strings.Join(parts, "\n\n---\n\n") return stack.Parts()
} }
// BuildSystemPromptWithCache returns the cached system prompt if available // BuildSystemPromptWithCache returns the cached system prompt if available
@ -230,6 +319,19 @@ func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []st
totalChars += 7 // separator \n\n---\n\n 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 != "" { if summary != "" {
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages // Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " + const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
@ -548,6 +650,20 @@ func (cb *ContextBuilder) BuildMessages(
channel, chatID, senderID, senderDisplayName string, channel, chatID, senderID, senderDisplayName string,
activeSkills ...string, activeSkills ...string,
) []providers.Message { ) []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{} messages := []providers.Message{}
// The static part (identity, bootstrap, skills, memory) is cached locally to // The static part (identity, bootstrap, skills, memory) is cached locally to
@ -562,7 +678,7 @@ func (cb *ContextBuilder) BuildMessages(
staticPrompt := cb.BuildSystemPromptWithCache() staticPrompt := cb.BuildSystemPromptWithCache()
// Build short dynamic context (time, runtime, session) — changes per request // 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. // Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can // Keeping all system content in one message ensures every provider adapter can
@ -573,25 +689,77 @@ func (cb *ContextBuilder) BuildMessages(
// cache-aware adapters (Anthropic) can set per-block cache_control. // cache-aware adapters (Anthropic) can set per-block cache_control.
// The static block is marked "ephemeral" — its prefix hash is stable // The static block is marked "ephemeral" — its prefix hash is stable
// across requests, enabling LLM-side KV cache reuse. // across requests, enabling LLM-side KV cache reuse.
stringParts := []string{staticPrompt, dynamicCtx} stringParts := []string{staticPrompt}
contentBlocks := []providers.ContentBlock{ contentBlocks := []providers.ContentBlock{
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, promptContentBlock(PromptPart{
{Type: "text", Text: dynamicCtx}, 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 != "" { promptParts := append([]PromptPart(nil), req.Overlays...)
stringParts = append(stringParts, skillsText) promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText}) 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 != "" { if len(promptParts) > 0 {
summaryText := fmt.Sprintf( for _, overlay := range sortPromptParts(promptParts) {
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ if strings.TrimSpace(overlay.Content) == "" {
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", continue
summary) }
stringParts = append(stringParts, summaryText) if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil {
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) 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") fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
@ -608,7 +776,8 @@ func (cb *ContextBuilder) BuildMessages(
"static_chars": len(staticPrompt), "static_chars": len(staticPrompt),
"dynamic_chars": len(dynamicCtx), "dynamic_chars": len(dynamicCtx),
"total_chars": len(fullSystemPrompt), "total_chars": len(fullSystemPrompt),
"has_summary": summary != "", "has_summary": req.Summary != "",
"overlays": len(req.Overlays),
"cached": isCached, "cached": isCached,
}) })
@ -619,7 +788,7 @@ func (cb *ContextBuilder) BuildMessages(
"preview": preview, "preview": preview,
}) })
history = sanitizeHistoryForProvider(history) history := sanitizeHistoryForProvider(req.History)
// Single system message containing all context — compatible with all providers. // Single system message containing all context — compatible with all providers.
// SystemParts enables cache-aware adapters to set per-block cache_control; // SystemParts enables cache-aware adapters to set per-block cache_control;
@ -636,15 +805,8 @@ func (cb *ContextBuilder) BuildMessages(
// Add current user message. Media-only turns must still be preserved so // Add current user message. Media-only turns must still be preserved so
// multimodal providers receive the uploaded image even when the user sends // multimodal providers receive the uploaded image even when the user sends
// no accompanying text. // no accompanying text.
if strings.TrimSpace(currentMessage) != "" || len(media) > 0 { if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 {
msg := providers.Message{ messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media))
Role: "user",
Content: currentMessage,
}
if len(media) > 0 {
msg.Media = append([]string(nil), media...)
}
messages = append(messages, msg)
} }
return messages return messages
@ -870,6 +1032,26 @@ The following skills are active for this request. Follow them when relevant.
%s`, content) %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 { func (cb *ContextBuilder) ListSkillNames() []string {
if cb.skillsLoader == nil { if cb.skillsLoader == nil {
return nil return nil

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"reflect"
"sort" "sort"
"sync" "sync"
"time" "time"
@ -325,6 +326,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM
switch decision.normalizedAction() { switch decision.normalizedAction() {
case HookActionContinue, HookActionModify: case HookActionContinue, HookActionModify:
if next != nil { if next != nil {
next = hm.applyBeforeLLMControls(reg.Name, current, next)
current = next current = next
} }
case HookActionAbortTurn, HookActionHardAbort: case HookActionAbortTurn, HookActionHardAbort:
@ -367,6 +369,84 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL
return current, HookDecision{Action: HookActionContinue} 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( func (hm *HookManager) BeforeTool(
ctx context.Context, ctx context.Context,
call *ToolCallHookRequest, call *ToolCallHookRequest,
@ -788,7 +868,7 @@ func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse {
func cloneStringAnyMap(src map[string]any) map[string]any { func cloneStringAnyMap(src map[string]any) map[string]any {
if len(src) == 0 { if len(src) == 0 {
return nil return map[string]any{}
} }
cloned := make(map[string]any, len(src)) cloned := make(map[string]any, len(src))

View file

@ -2,6 +2,7 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"os" "os"
"strings" "strings"
@ -149,6 +150,268 @@ func (h *llmObserverHook) AfterLLM(
return next, HookDecision{Action: HookActionModify}, nil 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) { func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
provider := &llmHookTestProvider{} provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider) al, agent, cleanup := newHookTestLoop(t, provider)
@ -1168,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 { func filterEvents(events []Event, kind EventKind) []Event {
var result []Event var result []Event
for _, evt := range events { for _, evt := range events {

View file

@ -81,13 +81,18 @@ toolLoop:
) )
if shouldPublishToolFeedback(al.cfg, ts) { if shouldPublishToolFeedback(al.cfg, ts) {
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
toolFeedbackExplanation := toolFeedbackExplanationForToolCall( toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
exec.response, exec.response,
tc, tc,
messages, messages,
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), toolFeedbackMaxLen,
)
feedbackMsg := utils.FormatToolFeedbackMessage(
toolName,
toolFeedbackExplanation,
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
) )
feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
fbCancel() fbCancel()
@ -260,7 +265,7 @@ toolLoop:
case result, ok := <-ts.pendingResults: case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" { if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM) content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} msg := subTurnResultPromptMessage(content)
messages = append(messages, msg) messages = append(messages, msg)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
} }
@ -358,13 +363,18 @@ toolLoop:
) )
if shouldPublishToolFeedback(al.cfg, ts) { if shouldPublishToolFeedback(al.cfg, ts) {
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
toolFeedbackExplanation := toolFeedbackExplanationForToolCall( toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
exec.response, exec.response,
tc, tc,
messages, messages,
al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), toolFeedbackMaxLen,
)
feedbackMsg := utils.FormatToolFeedbackMessage(
toolName,
toolFeedbackExplanation,
toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen),
) )
feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation)
fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second)
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
fbCancel() fbCancel()
@ -631,7 +641,7 @@ toolLoop:
case result, ok := <-ts.pendingResults: case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" { if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM) content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} msg := subTurnResultPromptMessage(content)
messages = append(messages, msg) messages = append(messages, msg)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
} }

View file

@ -40,8 +40,12 @@ func (p *Pipeline) Finalize(
ts.setPhase(TurnPhaseFinalizing) ts.setPhase(TurnPhaseFinalizing)
ts.setFinalContent(finalContent) ts.setFinalContent(finalContent)
if !ts.opts.NoHistory { if !ts.opts.NoHistory {
finalMsg := providers.Message{Role: "assistant", Content: finalContent} finalMsg := providers.Message{
ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) Role: "assistant",
Content: finalContent,
ReasoningContent: responseReasoningContent(exec.response),
}
ts.agent.Sessions.AddFullMessage(ts.sessionKey, finalMsg)
ts.recordPersistedMessage(finalMsg) ts.recordPersistedMessage(finalMsg)
ts.ingestMessage(turnCtx, al, finalMsg) ts.ingestMessage(turnCtx, al, finalMsg)
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {

View file

@ -319,10 +319,8 @@ func (p *Pipeline) CallLLM(
exec.history = asmResp.History exec.history = asmResp.History
exec.summary = asmResp.Summary exec.summary = asmResp.Summary
} }
exec.messages = ts.agent.ContextBuilder.BuildMessages( exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
exec.history, exec.summary, "", promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil),
nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
) )
exec.callMessages = exec.messages exec.callMessages = exec.messages
if exec.gracefulTerminal { if exec.gracefulTerminal {
@ -384,10 +382,7 @@ func (p *Pipeline) CallLLM(
} }
} }
reasoningContent := exec.response.Reasoning reasoningContent := responseReasoningContent(exec.response)
if reasoningContent == "" {
reasoningContent = exec.response.ReasoningContent
}
if ts.channel == "pico" { if ts.channel == "pico" {
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
} else { } else {
@ -496,7 +491,7 @@ func (p *Pipeline) CallLLM(
assistantMsg := providers.Message{ assistantMsg := providers.Message{
Role: "assistant", Role: "assistant",
Content: exec.response.Content, Content: exec.response.Content,
ReasoningContent: exec.response.ReasoningContent, ReasoningContent: reasoningContent,
} }
for _, tc := range exec.normalizedToolCalls { for _, tc := range exec.normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments) argumentsJSON, _ := json.Marshal(tc.Arguments)

View file

@ -31,16 +31,8 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
} }
ts.captureRestorePoint(history, summary) ts.captureRestorePoint(history, summary)
messages := ts.agent.ContextBuilder.BuildMessages( messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(
history, promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
summary,
ts.userMessage,
ts.media,
ts.channel,
ts.chatID,
ts.opts.Dispatch.SenderID(),
ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
) )
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
@ -69,22 +61,15 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
history = resp.History history = resp.History
summary = resp.Summary summary = resp.Summary
} }
messages = ts.agent.ContextBuilder.BuildMessages( messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
history, summary, ts.userMessage, promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
ts.media, ts.channel, ts.chatID,
ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
) )
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
} }
} }
if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) {
rootMsg := providers.Message{ rootMsg := userPromptMessage(ts.userMessage, ts.media)
Role: "user",
Content: ts.userMessage,
Media: append([]string(nil), ts.media...),
}
if len(rootMsg.Media) > 0 { if len(rootMsg.Media) > 0 {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg)
} else { } else {

496
pkg/agent/prompt.go Normal file
View file

@ -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
}
}

View file

@ -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
}

275
pkg/agent/prompt_test.go Normal file
View file

@ -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)
}
}

129
pkg/agent/prompt_turn.go Normal file
View file

@ -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,
)
}

View file

@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers
return fmt.Errorf("steering queue is not initialized") return fmt.Errorf("steering queue is not initialized")
} }
msg = steeringPromptMessage(msg)
if err := al.steering.pushScope(scope, msg); err != nil { if err := al.steering.pushScope(scope, msg); err != nil {
logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{ logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{
"error": err.Error(), "error": err.Error(),

View file

@ -10,6 +10,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
) )
@ -623,6 +624,10 @@ func (e *ephemeralSessionStore) AddMessage(_, role, content string) {
} }
func (e *ephemeralSessionStore) AddFullMessage(_ string, msg providers.Message) { func (e *ephemeralSessionStore) AddFullMessage(_ string, msg providers.Message) {
if messageutil.IsTransientAssistantThoughtMessage(msg) {
return
}
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
e.history = append(e.history, msg) e.history = append(e.history, msg)
@ -652,6 +657,7 @@ func (e *ephemeralSessionStore) SetSummary(_, summary string) {
func (e *ephemeralSessionStore) SetHistory(_ string, history []providers.Message) { func (e *ephemeralSessionStore) SetHistory(_ string, history []providers.Message) {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
history = messageutil.FilterInvalidHistoryMessages(history)
e.history = make([]providers.Message, len(history)) e.history = make([]providers.Message, len(history))
copy(e.history, history) copy(e.history, history)
e.truncateLocked() e.truncateLocked()

View file

@ -111,7 +111,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
case result, ok := <-ts.pendingResults: case result, ok := <-ts.pendingResults:
if ok && result != nil && result.ForLLM != "" { if ok && result != nil && result.ForLLM != "" {
content := al.cfg.FilterSensitiveData(result.ForLLM) content := al.cfg.FilterSensitiveData(result.ForLLM)
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} msg := subTurnResultPromptMessage(content)
pendingMessages = append(pendingMessages, msg) pendingMessages = append(pendingMessages, msg)
} }
default: default:

View file

@ -527,10 +527,7 @@ func (ts *turnState) interruptHintMessage() providers.Message {
if hint != "" { if hint != "" {
content += "\n\nInterrupt hint: " + hint content += "\n\nInterrupt hint: " + hint
} }
return providers.Message{ return interruptPromptMessage(content)
Role: "user",
Content: content,
}
} }
// ============================================================================= // =============================================================================

View file

@ -170,6 +170,20 @@ func dismissTrackedToolFeedbackMessage(
} }
} }
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 { func prepareToolFeedbackMessageContent(ch Channel, content string) string {
prepared := strings.TrimSpace(content) prepared := strings.TrimSpace(content)
if prepared == "" { if prepared == "" {
@ -183,6 +197,13 @@ func prepareToolFeedbackMessageContent(ch Channel, content string) string {
return prepared 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. // RecordPlaceholder registers a placeholder message for later editing.
// Implements PlaceholderRecorder. // Implements PlaceholderRecorder.
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
@ -264,6 +285,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
} }
isToolFeedback := outboundMessageIsToolFeedback(msg) isToolFeedback := outboundMessageIsToolFeedback(msg)
separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled()
// 3. If a stream already finalized this chat, stale tool feedback must be // 3. If a stream already finalized this chat, stale tool feedback must be
// dropped without consuming the final-response marker. Streaming finalization // dropped without consuming the final-response marker. Streaming finalization
@ -288,14 +310,28 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
} }
} }
if !isToolFeedback { if !isToolFeedback {
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) if separateToolFeedbackMessages {
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
} else {
dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context)
}
} }
return nil, true return nil, true
} }
if separateToolFeedbackMessages {
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
}
// 5. Try editing placeholder // 5. Try editing placeholder
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { 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 editor, ok := ch.(MessageEditor); ok { if editor, ok := ch.(MessageEditor); ok {
content := msg.Content content := msg.Content
trackedContent := msg.Content trackedContent := msg.Content
@ -345,6 +381,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. // 3. Clear any finalized stream marker for this chat before media delivery.
m.streamActive.LoadAndDelete(key) m.streamActive.LoadAndDelete(key)
if m.toolFeedbackSeparateMessagesEnabled() {
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
}
// 4. Delete placeholder if present. // 4. Delete placeholder if present.
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
@ -408,15 +448,26 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (
return &finalizeHookStreamer{ return &finalizeHookStreamer{
Streamer: streamer, Streamer: streamer,
onFinalize: func(finalizeCtx context.Context) { onFinalize: func(finalizeCtx context.Context) {
dismissTrackedToolFeedbackMessage( if m.toolFeedbackSeparateMessagesEnabled() {
finalizeCtx, clearTrackedToolFeedbackMessage(
ch, ch,
chatID, chatID,
&bus.InboundContext{ &bus.InboundContext{
Channel: channelName, Channel: channelName,
ChatID: chatID, ChatID: chatID,
}, },
) )
} else {
dismissTrackedToolFeedbackMessage(
finalizeCtx,
ch,
chatID,
&bus.InboundContext{
Channel: channelName,
ChatID: chatID,
},
)
}
m.streamActive.Store(key, true) m.streamActive.Store(key, true)
}, },
}, true }, true

View file

@ -804,6 +804,20 @@ type mockResolvedToolFeedbackEditor struct {
resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string 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( func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID(
chatID string, chatID string,
outboundCtx *bus.InboundContext, outboundCtx *bus.InboundContext,
@ -1062,6 +1076,101 @@ func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t
} }
} }
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_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) { func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) {
m := newTestManager() m := newTestManager()
m.streamActive.Store("test:123", true) m.streamActive.Store("test:123", true)
@ -1153,6 +1262,38 @@ func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) {
} }
} }
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) { func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) {
msg := testOutboundMessage(bus.OutboundMessage{ msg := testOutboundMessage(bus.OutboundMessage{
Channel: "test", Channel: "test",
@ -1232,6 +1373,49 @@ func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) {
} }
} }
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) { func TestGetStreamer_FinalizeDismissesResolvedTrackedToolFeedback(t *testing.T) {
m := newTestManager() m := newTestManager()
ch := &mockStreamingChannel{ ch := &mockStreamingChannel{

View file

@ -2,9 +2,13 @@ package telegram
import ( import (
"fmt" "fmt"
"html"
"regexp"
"strings" "strings"
) )
var reRawURL = regexp.MustCompile(`https?://[^\s<]+`)
func markdownToTelegramHTML(text string) string { func markdownToTelegramHTML(text string) string {
if text == "" { if text == "" {
return "" return ""
@ -19,6 +23,9 @@ func markdownToTelegramHTML(text string) string {
links := extractLinks(text) links := extractLinks(text)
text = links.text text = links.text
rawURLs := extractRawURLs(text)
text = rawURLs.text
text = reHeading.ReplaceAllString(text, "$1") text = reHeading.ReplaceAllString(text, "$1")
text = reBlockquote.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1")
@ -43,10 +50,19 @@ func markdownToTelegramHTML(text string) string {
for i, lnk := range links.links { for i, lnk := range links.links {
label := escapeHTML(lnk[0]) label := escapeHTML(lnk[0])
url := lnk[1] url := escapeHTMLAttr(lnk[1])
text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`<a href="%s">%s</a>`, url, label)) text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`<a href="%s">%s</a>`, url, label))
} }
for i, rawURL := range rawURLs.urls {
escaped := escapeHTML(rawURL)
text = strings.ReplaceAll(
text,
fmt.Sprintf("\x00RU%d\x00", i),
fmt.Sprintf(`<a href="%s">%s</a>`, escapeHTMLAttr(rawURL), escaped),
)
}
for i, code := range inlineCodes.codes { for i, code := range inlineCodes.codes {
escaped := escapeHTML(code) escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped)) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
@ -92,6 +108,11 @@ type codeBlockMatch struct {
codes []string codes []string
} }
type rawURLMatch struct {
text string
urls []string
}
func extractCodeBlocks(text string) codeBlockMatch { func extractCodeBlocks(text string) codeBlockMatch {
matches := reCodeBlock.FindAllStringSubmatch(text, -1) matches := reCodeBlock.FindAllStringSubmatch(text, -1)
@ -110,6 +131,24 @@ func extractCodeBlocks(text string) codeBlockMatch {
return codeBlockMatch{text: text, codes: codes} 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 { type inlineCodeMatch struct {
text string text string
codes []string codes []string
@ -139,3 +178,7 @@ func escapeHTML(text string) string {
text = strings.ReplaceAll(text, ">", "&gt;") text = strings.ReplaceAll(text, ">", "&gt;")
return text return text
} }
func escapeHTMLAttr(text string) string {
return html.EscapeString(text)
}

View file

@ -32,6 +32,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "[click here](https://example.com/path)", input: "[click here](https://example.com/path)",
expected: `<a href="https://example.com/path">click here</a>`, expected: `<a href="https://example.com/path">click here</a>`,
}, },
{
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 <a href="https://accounts.google.com/o/oauth2/auth?response_type=code&amp;client_id=test-client&amp;redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&amp;code_challenge=abc_def&amp;code_challenge_method=S256">https://accounts.google.com/o/oauth2/auth?response_type=code&amp;client_id=test-client&amp;redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&amp;code_challenge=abc_def&amp;code_challenge_method=S256</a>`,
},
{ {
name: "link with underscores in URL is not corrupted by italic regex", 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. // 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)", input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
expected: `<a href="https://a.com/path_one">first</a> and <a href="https://b.com/path_two_x">second</a>`, expected: `<a href="https://a.com/path_one">first</a> and <a href="https://b.com/path_two_x">second</a>`,
}, },
{
name: "markdown link query params are escaped in href",
input: "[oauth](https://example.com/cb?response_type=code&client_id=test-client)",
expected: `<a href="https://example.com/cb?response_type=code&amp;client_id=test-client">oauth</a>`,
},
{ {
name: "link label with HTML special chars is escaped", name: "link label with HTML special chars is escaped",
input: "[a & b](https://example.com)", input: "[a & b](https://example.com)",
@ -55,6 +65,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "a & b < c > d", input: "a & b < c > d",
expected: "a &amp; b &lt; c &gt; d", expected: "a &amp; b &lt; c &gt; d",
}, },
{
name: "code block with language",
input: "```json\n{\n \"path\": \"README.md\"\n}\n```",
expected: "<pre><code>{\n \"path\": \"README.md\"\n}\n</code></pre>",
},
} }
for _, tc := range cases { for _, tc := range cases {

View file

@ -247,8 +247,9 @@ type SubTurnConfig struct {
} }
type ToolFeedbackConfig struct { type ToolFeedbackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` 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"` 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 { type AgentDefaults struct {
@ -299,6 +300,13 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
return d.ToolFeedback.Enabled 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. // 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. // It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string { func (d *AgentDefaults) GetModelName() string {

View file

@ -787,6 +787,9 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
if cfg.Agents.Defaults.ToolFeedback.Enabled { if cfg.Agents.Defaults.ToolFeedback.Enabled {
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false") 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) { func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
@ -807,6 +810,9 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
if cfg.Agents.Defaults.ToolFeedback.Enabled { if cfg.Agents.Defaults.ToolFeedback.Enabled {
t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file") 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) { func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {

View file

@ -35,8 +35,9 @@ func DefaultConfig() *Config {
SummarizeTokenPercent: 75, SummarizeTokenPercent: 75,
SteeringMode: "one-at-a-time", SteeringMode: "one-at-a-time",
ToolFeedback: ToolFeedbackConfig{ ToolFeedback: ToolFeedbackConfig{
Enabled: false, Enabled: false,
MaxArgsLength: 300, MaxArgsLength: 300,
SeparateMessages: false,
}, },
SplitOnMarker: false, SplitOnMarker: false,
}, },

View file

@ -102,7 +102,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig,
return fmt.Errorf("open process for job assignment: %w", err) 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(proc)
_ = windows.CloseHandle(job) _ = windows.CloseHandle(job)
if resources.token != 0 { if resources.token != 0 {

View file

@ -25,6 +25,24 @@ type headerTransport struct {
headers map[string]string 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) { func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original // Clone the request to avoid modifying the original
req = req.Clone(req.Context()) 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 // ServerConnection represents a connection to an MCP server
type ServerConnection struct { type ServerConnection struct {
Name string Name string
Client *mcp.Client Config config.MCPServerConfig
Session *mcp.ClientSession Client *mcp.Client
Tools []*mcp.Tool Session *mcp.ClientSession
Tools []*mcp.Tool
reconnectMu sync.Mutex
} }
// Manager manages multiple MCP server connections // Manager manages multiple MCP server connections
@ -113,6 +133,8 @@ type Manager struct {
wg sync.WaitGroup // tracks in-flight CallTool calls wg sync.WaitGroup // tracks in-flight CallTool calls
} }
var connectServerFunc = connectServer
// NewManager creates a new MCP manager // NewManager creates a new MCP manager
func NewManager() *Manager { func NewManager() *Manager {
return &Manager{ return &Manager{
@ -242,6 +264,28 @@ func (m *Manager) ConnectServer(
name string, name string,
cfg config.MCPServerConfig, cfg config.MCPServerConfig,
) error { ) 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", logger.InfoCF("mcp", "Connecting to MCP server",
map[string]any{ map[string]any{
"server": name, "server": name,
@ -267,14 +311,14 @@ func (m *Manager) ConnectServer(
} else if cfg.Command != "" { } else if cfg.Command != "" {
transportType = "stdio" transportType = "stdio"
} else { } else {
return fmt.Errorf("either URL or command must be provided") return nil, fmt.Errorf("either URL or command must be provided")
} }
} }
switch transportType { switch transportType {
case "sse", "http": case "sse", "http":
if cfg.URL == "" { 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. // Configure DisableStandaloneSSE based on transport type.
@ -316,7 +360,7 @@ func (m *Manager) ConnectServer(
transport = sseTransport transport = sseTransport
case "stdio": case "stdio":
if cfg.Command == "" { 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", logger.DebugCF("mcp", "Using stdio transport",
map[string]any{ map[string]any{
@ -324,7 +368,7 @@ func (m *Manager) ConnectServer(
"command": cfg.Command, "command": cfg.Command,
}) })
// Create command with context // 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 // Build environment variables with proper override semantics
// Use a map to ensure config variables override file variables // Use a map to ensure config variables override file variables
@ -341,7 +385,7 @@ func (m *Manager) ConnectServer(
if cfg.EnvFile != "" { if cfg.EnvFile != "" {
envVars, err := loadEnvFile(cfg.EnvFile) envVars, err := loadEnvFile(cfg.EnvFile)
if err != nil { 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 { for k, v := range envVars {
envMap[k] = v envMap[k] = v
@ -367,7 +411,7 @@ func (m *Manager) ConnectServer(
cmd.Env = env cmd.Env = env
transport = &isolatedCommandTransport{Command: cmd} transport = &isolatedCommandTransport{Command: cmd}
default: default:
return fmt.Errorf( return nil, fmt.Errorf(
"unsupported transport type: %s (supported: stdio, sse, http)", "unsupported transport type: %s (supported: stdio, sse, http)",
transportType, transportType,
) )
@ -376,7 +420,7 @@ func (m *Manager) ConnectServer(
// Connect to server // Connect to server
session, err := client.Connect(ctx, transport, nil) session, err := client.Connect(ctx, transport, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to connect: %w", err) return nil, fmt.Errorf("failed to connect: %w", err)
} }
// Get server info // Get server info
@ -390,38 +434,19 @@ func (m *Manager) ConnectServer(
}) })
// List available tools if supported // List available tools if supported
var tools []*mcp.Tool tools, err := listServerTools(ctx, name, session, initResult)
if initResult.Capabilities.Tools != nil { if err != nil {
for tool, err := range session.Tools(ctx, nil) { _ = session.Close()
if err != nil { return nil, err
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),
})
} }
// Store connection return &ServerConnection{
m.mu.Lock()
m.servers[name] = &ServerConnection{
Name: name, Name: name,
Config: cfg,
Client: client, Client: client,
Session: session, Session: session,
Tools: tools, Tools: tools,
} }, nil
m.mu.Unlock()
return nil
} }
// GetServers returns all connected servers // GetServers returns all connected servers
@ -480,12 +505,131 @@ func (m *Manager) CallTool(
result, err := conn.Session.CallTool(ctx, params) result, err := conn.Session.CallTool(ctx, params)
if err != nil { 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 nil, fmt.Errorf("failed to call tool: %w", err)
} }
return result, nil 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 // Close closes all server connections
func (m *Manager) Close() error { func (m *Manager) Close() error {
// Use Swap to atomically set closed=true and get the previous value // Use Swap to atomically set closed=true and get the previous value

View file

@ -2,11 +2,16 @@ package mcp
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config" "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) { func TestEnvFilePriority(t *testing.T) {
// Create a temporary .env file // Create a temporary .env file
tmpDir := t.TempDir() 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) { func TestClose_IdempotentOnEmptyManager(t *testing.T) {
mgr := NewManager() mgr := NewManager()
@ -306,3 +402,138 @@ func TestClose_IdempotentOnEmptyManager(t *testing.T) {
t.Fatalf("second close should be idempotent, got: %v", err) 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
}

View file

@ -10,12 +10,14 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
) )
const ( const (
@ -405,12 +407,9 @@ func (s *JSONLStore) promoteAliasHistoryLocked(
} }
func (s *JSONLStore) sessionHasVisibleContentLocked(sessionKey string, meta SessionMeta) (bool, error) { 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 return true, nil
} }
if meta.Count != 0 || meta.Skip != 0 {
return false, nil
}
history, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) history, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)
if err != nil { if err != nil {
return false, err return false, err
@ -482,6 +481,9 @@ func readMessages(path string, skip int) ([]providers.Message, error) {
lineNum, filepath.Base(path), err) lineNum, filepath.Base(path), err)
continue continue
} }
if messageutil.IsTransientAssistantThoughtMessage(msg) {
continue
}
msgs = append(msgs, msg) msgs = append(msgs, msg)
} }
if scanner.Err() != nil { if scanner.Err() != nil {
@ -494,28 +496,44 @@ func readMessages(path string, skip int) ([]providers.Message, error) {
return msgs, nil return msgs, nil
} }
// countLines counts the total number of non-empty lines in a .jsonl file. // scanRetainedMessageLines returns the total number of non-empty raw JSONL
// Used by TruncateHistory to reconcile a stale meta.Count without // lines plus the raw line numbers that survive readMessages filtering.
// the overhead of unmarshaling every message. // TruncateHistory uses this to compute keepLast against retained messages
func countLines(path string) (int, error) { // while preserving the raw-line skip offset stored in metadata.
func scanRetainedMessageLines(path string) (int, []int, error) {
f, err := os.Open(path) f, err := os.Open(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return 0, nil return 0, []int{}, nil
} }
if err != 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() defer f.Close()
n := 0 rawCount := 0
retained := make([]int, 0)
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
for scanner.Scan() { for scanner.Scan() {
if len(scanner.Bytes()) > 0 { line := scanner.Bytes()
n++ 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( func (s *JSONLStore) AddMessage(
@ -535,6 +553,10 @@ func (s *JSONLStore) AddFullMessage(
// addMsg is the shared implementation for AddMessage and AddFullMessage. // addMsg is the shared implementation for AddMessage and AddFullMessage.
func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
if messageutil.IsTransientAssistantThoughtMessage(msg) {
return nil
}
l := s.sessionLock(sessionKey) l := s.sessionLock(sessionKey)
l.Lock() l.Lock()
defer l.Unlock() defer l.Unlock()
@ -655,24 +677,26 @@ func (s *JSONLStore) TruncateHistory(
return err return err
} }
// Always reconcile meta.Count with the actual line count on disk. rawCount, retainedRawLines, scanErr := scanRetainedMessageLines(s.jsonlPath(sessionKey))
// A crash between the JSONL append and the meta update in addMsg if scanErr != nil {
// leaves meta.Count stale (e.g. file has 101 lines but meta says return scanErr
// 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
} }
meta.Count = n meta.Count = rawCount
if meta.Skip > meta.Count {
if keepLast <= 0 {
meta.Skip = meta.Count meta.Skip = meta.Count
} else { }
effective := meta.Count - meta.Skip
if keepLast < effective { activeStart := sort.Search(len(retainedRawLines), func(i int) bool {
meta.Skip = meta.Count - keepLast 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() meta.UpdatedAt = time.Now()
@ -684,6 +708,8 @@ func (s *JSONLStore) SetHistory(
sessionKey string, sessionKey string,
history []providers.Message, history []providers.Message,
) error { ) error {
history = messageutil.FilterInvalidHistoryMessages(history)
l := s.sessionLock(sessionKey) l := s.sessionLock(sessionKey)
l.Lock() l.Lock()
defer l.Unlock() defer l.Unlock()
@ -762,6 +788,8 @@ func (s *JSONLStore) Compact(
func (s *JSONLStore) rewriteJSONL( func (s *JSONLStore) rewriteJSONL(
sessionKey string, msgs []providers.Message, sessionKey string, msgs []providers.Message,
) error { ) error {
msgs = messageutil.FilterInvalidHistoryMessages(msgs)
var buf bytes.Buffer var buf bytes.Buffer
for i, msg := range msgs { for i, msg := range msgs {
line, err := json.Marshal(msg) line, err := json.Marshal(msg)

View file

@ -6,8 +6,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/providers" "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) { func TestGetHistory_EmptySession(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
ctx := context.Background() 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) { func TestSessionMetaScopeAndAliasesPersist(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
ctx := context.Background() 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) { func TestCrashRecovery_PartialLine(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
ctx := context.Background() ctx := context.Background()

View file

@ -58,7 +58,12 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
if data, err := readPidFileUnlocked(pidPath); err == nil { if data, err := readPidFileUnlocked(pidPath); err == nil {
if os.Getpid() != data.PID { if os.Getpid() != data.PID {
logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version) 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) 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) 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 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) { if !isProcessRunning(data.PID) {
logger.Debugf("process not running, remove pid file: %s", pidPath) logger.Debugf("process not running, remove pid file: %s", pidPath)
os.Remove(pidPath) os.Remove(pidPath)

View file

@ -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. // TestReadPidFileUnlockedInvalidJSON returns error for malformed content.
func TestReadPidFileUnlockedInvalidJSON(t *testing.T) { func TestReadPidFileUnlockedInvalidJSON(t *testing.T) {
dir := tmpDir(t) dir := tmpDir(t)

View file

@ -178,7 +178,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" { if apiBase == "" {
apiBase = getDefaultAPIBase(protocol) apiBase = getDefaultAPIBase(protocol)
} }
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
cfg.Proxy, cfg.Proxy,
@ -187,7 +187,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody, cfg.ExtraBody,
cfg.CustomHeaders, cfg.CustomHeaders,
), modelID, nil )
provider.SetProviderName(protocol)
return provider, modelID, nil
case "azure", "azure-openai": case "azure", "azure-openai":
// Azure OpenAI uses deployment-based URLs, api-key header auth, // Azure OpenAI uses deployment-based URLs, api-key header auth,
@ -257,7 +259,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" { if apiBase == "" {
apiBase = getDefaultAPIBase(protocol) apiBase = getDefaultAPIBase(protocol)
} }
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
cfg.Proxy, cfg.Proxy,
@ -266,7 +268,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody, cfg.ExtraBody,
cfg.CustomHeaders, cfg.CustomHeaders,
), modelID, nil )
provider.SetProviderName(protocol)
return provider, modelID, nil
case "gemini": case "gemini":
if cfg.APIKey() == "" && cfg.APIBase == "" { if cfg.APIKey() == "" && cfg.APIBase == "" {
@ -302,7 +306,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if _, ok := extraBody["reasoning_split"]; !ok { if _, ok := extraBody["reasoning_split"]; !ok {
extraBody["reasoning_split"] = true extraBody["reasoning_split"] = true
} }
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
cfg.Proxy, cfg.Proxy,
@ -311,7 +315,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
extraBody, extraBody,
cfg.CustomHeaders, cfg.CustomHeaders,
), modelID, nil )
provider.SetProviderName(protocol)
return provider, modelID, nil
case "anthropic": case "anthropic":
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
@ -330,7 +336,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey() == "" { if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
} }
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
cfg.APIKey(), cfg.APIKey(),
apiBase, apiBase,
cfg.Proxy, cfg.Proxy,
@ -339,7 +345,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.RequestTimeout, cfg.RequestTimeout,
cfg.ExtraBody, cfg.ExtraBody,
cfg.CustomHeaders, cfg.CustomHeaders,
), modelID, nil )
provider.SetProviderName(protocol)
return provider, modelID, nil
case "anthropic-messages": case "anthropic-messages":
// Anthropic Messages API with native format (HTTP-based, no SDK) // Anthropic Messages API with native format (HTTP-based, no SDK)

View file

@ -77,3 +77,10 @@ func (p *HTTPProvider) GetDefaultModel() string {
func (p *HTTPProvider) SupportsNativeSearch() bool { func (p *HTTPProvider) SupportsNativeSearch() bool {
return p.delegate.SupportsNativeSearch() return p.delegate.SupportsNativeSearch()
} }
func (p *HTTPProvider) SetProviderName(providerName string) {
if p == nil || p.delegate == nil {
return
}
p.delegate.SetProviderName(providerName)
}

View file

@ -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
}

View file

@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
@ -34,6 +35,7 @@ type (
type Provider struct { type Provider struct {
apiKey string apiKey string
apiBase string apiBase string
providerName string
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
httpClient *http.Client httpClient *http.Client
extraBody map[string]any // Additional fields to inject into request body 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 { func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
p := &Provider{ p := &Provider{
apiKey: apiKey, apiKey: apiKey,
@ -136,7 +144,7 @@ func (p *Provider) buildRequestBody(
requestBody := map[string]any{ requestBody := map[string]any{
"model": model, "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. // 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( func (p *Provider) Chat(
ctx context.Context, ctx context.Context,
messages []Message, messages []Message,

View file

@ -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 var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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, "") 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{ messages := []Message{
{Role: "user", Content: "What is 1+1?"}, {Role: "user", Content: "What is 1+1?"},
{Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, {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) t.Fatalf("Chat() error = %v", err)
} }
// Verify reasoning_content is preserved in the serialized request.
reqMessages, ok := requestBody["messages"].([]any) reqMessages, ok := requestBody["messages"].([]any)
if !ok { if !ok {
t.Fatalf("messages is not []any: %T", requestBody["messages"]) t.Fatalf("messages is not []any: %T", requestBody["messages"])
@ -247,11 +244,288 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
if !ok { if !ok {
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1])
} }
if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" { if _, exists := assistantMsg["reasoning_content"]; exists {
t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"]) 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) { func TestProviderChat_HTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)

View file

@ -61,6 +61,13 @@ type ContentBlock struct {
Type string `json:"type"` // "text" Type string `json:"type"` // "text"
Text string `json:"text"` Text string `json:"text"`
CacheControl *CacheControl `json:"cache_control,omitempty"` 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 Attachment struct {
@ -80,11 +87,24 @@ type Message struct {
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,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 ToolDefinition struct {
Type string `json:"type"` Type string `json:"type"`
Function ToolFunctionDefinition `json:"function"` 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 { type ToolFunctionDefinition struct {

View file

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
) )
type Session struct { 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. // 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. // This is used to save the full conversation flow including tool calls and tool results.
func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {
if messageutil.IsTransientAssistantThoughtMessage(msg) {
return
}
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
@ -196,8 +201,7 @@ func (sm *SessionManager) Save(key string) error {
Updated: stored.Updated, Updated: stored.Updated,
} }
if len(stored.Messages) > 0 { if len(stored.Messages) > 0 {
snapshot.Messages = make([]providers.Message, len(stored.Messages)) snapshot.Messages = messageutil.FilterInvalidHistoryMessages(stored.Messages)
copy(snapshot.Messages, stored.Messages)
} else { } else {
snapshot.Messages = []providers.Message{} snapshot.Messages = []providers.Message{}
} }
@ -270,6 +274,7 @@ func (sm *SessionManager) loadSessions() error {
if err := json.Unmarshal(data, &session); err != nil { if err := json.Unmarshal(data, &session); err != nil {
continue continue
} }
session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages)
sm.sessions[session.Key] = &session sm.sessions[session.Key] = &session
} }
@ -290,6 +295,7 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
if ok { if ok {
history = messageutil.FilterInvalidHistoryMessages(history)
// Create a deep copy to strictly isolate internal state // Create a deep copy to strictly isolate internal state
// from the caller's slice. // from the caller's slice.
msgs := make([]providers.Message, len(history)) msgs := make([]providers.Message, len(history))

View file

@ -15,6 +15,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
) )
// MCPManager defines the interface for MCP manager operations // 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) 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 // Parameters returns the tool parameters schema
func (t *MCPTool) Parameters() map[string]any { func (t *MCPTool) Parameters() map[string]any {
// The InputSchema is already a JSON Schema object // The InputSchema is already a JSON Schema object

View file

@ -11,6 +11,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
) )
// MockMCPManager is a mock implementation of MCPManager interface for testing // 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 // TestMCPTool_Description verifies tool description generation
func TestMCPTool_Description(t *testing.T) { func TestMCPTool_Description(t *testing.T) {
tests := []struct { tests := []struct {

View file

@ -58,8 +58,6 @@ var (
reSogouRealURL = regexp.MustCompile(`url=([^&]+)`) reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
) )
var preferredWebSearchLanguage atomic.Value
type APIKeyPool struct { type APIKeyPool struct {
keys []string keys []string
current uint32 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 { type BraveSearchProvider struct {
keyPool *APIKeyPool keyPool *APIKeyPool
proxy string proxy string
@ -1420,7 +1397,7 @@ func containsLatinLetter(text string) bool {
func prefersDuckDuckGoQuery(text string) bool { func prefersDuckDuckGoQuery(text string) bool {
trimmed := strings.TrimSpace(text) trimmed := strings.TrimSpace(text)
if trimmed == "" { if trimmed == "" {
return GetPreferredWebSearchLanguage() == "en" return false
} }
if containsHan(trimmed) { if containsHan(trimmed) {
return false return false
@ -1428,7 +1405,7 @@ func prefersDuckDuckGoQuery(text string) bool {
if containsLatinLetter(trimmed) { if containsLatinLetter(trimmed) {
return true return true
} }
return GetPreferredWebSearchLanguage() == "en" return false
} }
func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) { func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) {

View file

@ -1778,11 +1778,6 @@ func TestApplySogouRangeHint(t *testing.T) {
} }
func TestPrefersDuckDuckGoQuery(t *testing.T) { func TestPrefersDuckDuckGoQuery(t *testing.T) {
SetPreferredWebSearchLanguage("")
t.Cleanup(func() {
SetPreferredWebSearchLanguage("")
})
tests := []struct { tests := []struct {
name string name string
query string query string
@ -1805,19 +1800,9 @@ func TestPrefersDuckDuckGoQuery(t *testing.T) {
} }
} }
func TestPrefersDuckDuckGoQuery_FallsBackToPreferredLanguage(t *testing.T) { func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(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")
if prefersDuckDuckGoQuery("2026 04 15") { 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")
} }
} }

View file

@ -65,14 +65,6 @@ func NewAPIKeyPool(keys []string) *APIKeyPool {
return integrationtools.NewAPIKeyPool(keys) return integrationtools.NewAPIKeyPool(keys)
} }
func SetPreferredWebSearchLanguage(lang string) {
integrationtools.SetPreferredWebSearchLanguage(lang)
}
func GetPreferredWebSearchLanguage() string {
return integrationtools.GetPreferredWebSearchLanguage()
}
func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
return integrationtools.WebSearchToolOptionsFromConfig(cfg) return integrationtools.WebSearchToolOptionsFromConfig(cfg)
} }

View file

@ -352,6 +352,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
name, _ := fn["name"].(string) name, _ := fn["name"].(string)
desc, _ := fn["description"].(string) desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any) params, _ := fn["parameters"].(map[string]any)
metadata := promptMetadataForTool(entry.Tool)
definitions = append(definitions, providers.ToolDefinition{ definitions = append(definitions, providers.ToolDefinition{
Type: "function", Type: "function",
@ -360,11 +361,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
Description: desc, Description: desc,
Parameters: params, Parameters: params,
}, },
PromptLayer: metadata.Layer,
PromptSlot: metadata.Slot,
PromptSource: metadata.Source,
}) })
} }
return definitions 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. // List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string { func (r *ToolRegistry) List() []string {
r.mu.RLock() r.mu.RLock()

View file

@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T
return m.result return m.result
} }
type mockPromptMetadataTool struct {
mockRegistryTool
metadata PromptMetadata
}
func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata {
return m.metadata
}
type mockAsyncRegistryTool struct { type mockAsyncRegistryTool struct {
mockRegistryTool mockRegistryTool
lastCB AsyncCallback 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) { func TestToolRegistry_Clone(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
r.Register(newMockTool("read_file", "reads files")) r.Register(newMockTool("read_file", "reads files"))

View file

@ -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." 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 { func (t *RegexSearchTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "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." 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 { func (t *BM25SearchTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",

View file

@ -14,6 +14,24 @@ type Tool interface {
Execute(ctx context.Context, args map[string]any) *ToolResult 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) --- // --- Request-scoped tool context (channel / chatID) ---
// //
// Carried via context.Value so that concurrent tool calls each receive // Carried via context.Value so that concurrent tool calls each receive

View file

@ -22,12 +22,20 @@ type (
Tool = toolshared.Tool Tool = toolshared.Tool
AsyncCallback = toolshared.AsyncCallback AsyncCallback = toolshared.AsyncCallback
AsyncExecutor = toolshared.AsyncExecutor AsyncExecutor = toolshared.AsyncExecutor
PromptMetadata = toolshared.PromptMetadata
PromptMetadataProvider = toolshared.PromptMetadataProvider
ToolResult = toolshared.ToolResult ToolResult = toolshared.ToolResult
) )
const ( const (
handledToolLLMNote = toolshared.HandledToolLLMNote handledToolLLMNote = toolshared.HandledToolLLMNote
artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote 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 { func WithToolContext(ctx context.Context, channel, chatID string) context.Context {

View file

@ -7,21 +7,31 @@ import (
const ToolFeedbackContinuationHint = "Continuing the current task." const ToolFeedbackContinuationHint = "Continuing the current task."
// FormatToolFeedbackMessage renders the model-provided explanation for why a // FormatToolFeedbackMessage renders a tool feedback message for chat channels.
// tool is being executed. When the model does not provide one, it keeps only // It keeps the tool name on the first line for animation and can include both
// the tool line and does not expose raw arguments or fallback text. // a human explanation and the serialized tool arguments in the body.
func FormatToolFeedbackMessage(toolName, explanation string) string { func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string {
toolName = strings.TrimSpace(toolName) toolName = strings.TrimSpace(toolName)
explanation = strings.TrimSpace(explanation) 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 == "" { if toolName == "" {
return explanation return body
} }
if explanation == "" { if body == "" {
return fmt.Sprintf("\U0001f527 `%s`", toolName) return fmt.Sprintf("\U0001f527 `%s`", toolName)
} }
return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation) return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, body)
} }
// FitToolFeedbackMessage keeps tool feedback within a single outbound message. // FitToolFeedbackMessage keeps tool feedback within a single outbound message.

View file

@ -6,29 +6,38 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
got := FormatToolFeedbackMessage( got := FormatToolFeedbackMessage(
"read_file", "read_file",
"I will read README.md first to confirm the current project structure.", "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." 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 { if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
} }
} }
func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) { func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
got := FormatToolFeedbackMessage("read_file", "") got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
want := "\U0001f527 `read_file`" want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
if got != want { if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
} }
} }
func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) { func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
got := FormatToolFeedbackMessage("", "Continue drafting the final response.") got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "")
want := "Continue drafting the final response." want := "Continue drafting the final response."
if got != want { if got != want {
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", 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) { func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
got := FitToolFeedbackMessage( got := FitToolFeedbackMessage(
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",

186
scripts/copydir.go Normal file
View file

@ -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 <src> <dst>\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()
}

View file

@ -2,15 +2,24 @@
build-android-arm64 build-android-bundle build-android-arm64 build-android-bundle
# Go variables # Go variables
GO?=CGO_ENABLED=0 go GO?=go
WEB_GO?=$(GO) WEB_GO?=$(GO)
CGO_ENABLED?=0
GO_BUILD_TAGS?=goolm,stdjson GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS) 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 variables
BUILD_DIR=build BUILD_DIR=build
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher EXT=
OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64 OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT)
OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT)
FRONTEND_DIR=frontend FRONTEND_DIR=frontend
FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp
BACKEND_DIR=backend BACKEND_DIR=backend
@ -19,18 +28,47 @@ PICOCLAW_BINARY_NAME=picoclaw
PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME)) PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME))
LAUNCHER_GUI_LDFLAG= LAUNCHER_GUI_LDFLAG=
ifeq ($(OS),Windows_NT)
POWERSHELL=powershell -NoProfile -Command
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
endif
# Version # Version
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") ifeq ($(OS),Windows_NT)
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
BUILD_TIME=$(shell date +%FT%T%z) GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}') 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 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 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 # OS detection
UNAME_S:=$(shell uname -s) ifeq ($(OS),Windows_NT)
UNAME_M:=$(shell uname -m) 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 # Platform-specific settings
ifeq ($(UNAME_S),Linux) ifeq ($(UNAME_S),Linux)
@ -62,7 +100,14 @@ else ifeq ($(UNAME_S),Darwin)
endif endif
else ifeq ($(UNAME_S),Windows) else ifeq ($(UNAME_S),Windows)
PLATFORM=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 PICOCLAW_BINARY_NAME=picoclaw.exe
LAUNCHER_GUI_LDFLAG=-H=windowsgui LAUNCHER_GUI_LDFLAG=-H=windowsgui
else else
@ -91,21 +136,36 @@ dev-backend:
# Build frontend and embed into Go binary # Build frontend and embed into Go binary
build: build-frontend 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)")" @mkdir -p "$$(dirname "$(OUTPUT)")"
endif
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/ ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
# Build launcher for Android ARM64 (frontend must already be built) # Build launcher for Android ARM64 (frontend must already be built)
build-android-arm64: build-frontend 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) @mkdir -p $(BUILD_DIR)
endif
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
# Build launcher for all Android architectures # Build launcher for all Android architectures
build-android-bundle: build-frontend 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) @mkdir -p $(BUILD_DIR)
endif
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/ 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" @echo "All Android launcher builds complete"
build-frontend: 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}')"; \ @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
[ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \ [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
@ -115,12 +175,17 @@ build-frontend:
(cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \ (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \ printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
fi fi
endif
@echo "Building frontend..." @echo "Building frontend..."
@cd $(FRONTEND_DIR) && pnpm build:backend @cd $(FRONTEND_DIR) && pnpm build:backend
build-dev-picoclaw: build-dev-picoclaw:
@echo "Building picoclaw for launcher development..." @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)")" @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
endif
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
# Run all tests # Run all tests
@ -135,5 +200,10 @@ lint:
# Clean build artifacts # Clean build artifacts
clean: 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) rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
endif

View file

@ -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) {}

View file

@ -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
}

View file

@ -164,7 +164,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
`$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`, `$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`,
pid, pid,
) )
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output() out, err := launcherExecCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
if err == nil { if err == nil {
cmdline := strings.TrimSpace(string(out)) cmdline := strings.TrimSpace(string(out))
if cmdline != "" { if cmdline != "" {
@ -173,7 +173,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
} }
// Fallback: determine only whether the process still exists. // 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 { if err != nil {
return false, false return false, false
} }
@ -187,7 +187,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
if strings.Contains(line, "\"picoclaw.exe\"") { if strings.Contains(line, "\"picoclaw.exe\"") {
return true, true return true, true
} }
return false, false return false, true
} }
if strings.Contains(line, "no tasks are running") { if strings.Contains(line, "no tasks are running") {
return false, true return false, true
@ -195,7 +195,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
return false, true 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 { if err != nil {
return false, false return false, false
} }
@ -706,6 +706,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...) cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...)
applyLauncherProcAttrs(cmd)
cmd.Env = os.Environ() cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that // Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same // GetConfigPath() already reads, so the gateway sub-process uses the same

View file

@ -89,7 +89,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Skills and tools support/actions // Skills and tools support/actions
h.registerSkillRoutes(mux) h.registerSkillRoutes(mux)
h.registerToolRoutes(mux) h.registerToolRoutes(mux)
h.registerUIRoutes(mux)
// OS startup / launch-at-login // OS startup / launch-at-login
h.registerStartupRoutes(mux) h.registerStartupRoutes(mux)

View file

@ -2,6 +2,7 @@ package api
import ( import (
"bufio" "bufio"
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@ -15,6 +16,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
@ -48,6 +50,7 @@ type sessionListItem struct {
type sessionChatMessage struct { type sessionChatMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Kind string `json:"kind,omitempty"`
Media []string `json:"media,omitempty"` Media []string `json:"media,omitempty"`
Attachments []sessionChatAttachment `json:"attachments,omitempty"` Attachments []sessionChatAttachment `json:"attachments,omitempty"`
} }
@ -153,6 +156,9 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag
if err := json.Unmarshal(line, &msg); err != nil { if err := json.Unmarshal(line, &msg); err != nil {
continue continue
} }
if messageutil.IsTransientAssistantThoughtMessage(msg) {
continue
}
msgs = append(msgs, msg) msgs = append(msgs, msg)
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
@ -473,6 +479,18 @@ func sessionChatMessagePreview(msg sessionChatMessage) string {
} }
func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage { 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)) transcript := make([]sessionChatMessage, 0, len(messages))
for _, msg := range messages { for _, msg := range messages {
@ -494,11 +512,14 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
} }
case "assistant": case "assistant":
// Reasoning-only assistant messages are transient display artifacts and if messageutil.IsTransientAssistantThoughtMessage(msg) {
// should not be restored from session history.
if assistantMessageTransientThought(msg) {
continue continue
} }
if includeThoughts {
if thoughtMsg, ok := assistantThoughtMessage(msg); ok {
transcript = append(transcript, thoughtMsg)
}
}
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength) toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
if len(toolSummaryMessages) > 0 { if len(toolSummaryMessages) > 0 {
@ -593,7 +614,15 @@ func toolSummaryContainsContent(summary, content string) bool {
} }
_, body, hasBody := strings.Cut(summary, "\n") _, body, hasBody := strings.Cut(summary, "\n")
return hasBody && strings.TrimSpace(body) == content if !hasBody {
return false
}
body = strings.TrimSpace(body)
if body == content {
return true
}
firstSection, _, _ := strings.Cut(body, "\n```")
return strings.TrimSpace(firstSection) == content
} }
func sessionAttachments(msg providers.Message) []sessionChatAttachment { func sessionAttachments(msg providers.Message) []sessionChatAttachment {
@ -672,18 +701,25 @@ func sessionAttachmentType(attachment providers.Attachment) string {
} }
} }
func assistantMessageTransientThought(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == "" &&
strings.TrimSpace(msg.ReasoningContent) != "" &&
len(msg.ToolCalls) == 0 &&
len(msg.Media) == 0 &&
len(msg.Attachments) == 0
}
func assistantMessageInternalOnly(msg providers.Message) bool { func assistantMessageInternalOnly(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText return strings.TrimSpace(msg.Content) == handledToolResponseSummaryText
} }
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 visibleAssistantToolSummaryMessages( func visibleAssistantToolSummaryMessages(
toolCalls []providers.ToolCall, toolCalls []providers.ToolCall,
toolFeedbackMaxArgsLength int, toolFeedbackMaxArgsLength int,
@ -714,7 +750,8 @@ func visibleAssistantToolSummaryMessages(
Role: "assistant", Role: "assistant",
Content: utils.FormatToolFeedbackMessage( Content: utils.FormatToolFeedbackMessage(
name, name,
visibleAssistantToolSummaryText(tc, toolFeedbackMaxArgsLength), visibleAssistantToolFeedbackExplanation(tc, toolFeedbackMaxArgsLength),
visibleAssistantToolArgsPreview(tc, toolFeedbackMaxArgsLength),
), ),
}) })
} }
@ -722,7 +759,7 @@ func visibleAssistantToolSummaryMessages(
return messages return messages
} }
func visibleAssistantToolSummaryText( func visibleAssistantToolFeedbackExplanation(
tc providers.ToolCall, tc providers.ToolCall,
toolFeedbackMaxArgsLength int, toolFeedbackMaxArgsLength int,
) string { ) string {
@ -731,18 +768,32 @@ func visibleAssistantToolSummaryText(
return utils.Truncate(explanation, toolFeedbackMaxArgsLength) return utils.Truncate(explanation, toolFeedbackMaxArgsLength)
} }
} }
return ""
}
func visibleAssistantToolArgsPreview(
tc providers.ToolCall,
toolFeedbackMaxArgsLength int,
) string {
argsJSON := "" argsJSON := ""
if tc.Function != nil { if tc.Function != nil {
argsJSON = tc.Function.Arguments argsJSON = tc.Function.Arguments
} }
if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 {
if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { if encodedArgs, err := json.MarshalIndent(tc.Arguments, "", " "); err == nil {
argsJSON = string(encodedArgs) argsJSON = string(encodedArgs)
} }
} }
argsJSON = strings.TrimSpace(argsJSON)
if argsJSON == "" {
return ""
}
var pretty bytes.Buffer
if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil {
argsJSON = pretty.String()
}
return utils.Truncate(strings.TrimSpace(argsJSON), toolFeedbackMaxArgsLength) return utils.Truncate(argsJSON, toolFeedbackMaxArgsLength)
} }
func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage {
@ -962,7 +1013,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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{

View file

@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/memory"
@ -101,6 +102,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) { func TestHandleListSessions_TitleUsesFirstUserMessage(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -423,7 +482,7 @@ func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
} }
} }
func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -460,6 +519,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
Messages []struct { Messages []struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
Kind string `json:"kind"`
} `json:"messages"` } `json:"messages"`
} }
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
@ -476,6 +536,180 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(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 []struct {
Role string `json:"role"`
Content string `json:"content"`
Kind string `json:"kind"`
} `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 []struct {
Role string `json:"role"`
Content string `json:"content"`
Kind string `json:"kind"`
} `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")
if !strings.Contains(resp.Messages[5].Content, "`read_file`") {
t.Fatalf("messages[5] = %#v, want read_file tool summary", resp.Messages[5])
}
assertMessage(6, "user", "", "turn3")
if !strings.Contains(resp.Messages[7].Content, "`list_dir`") {
t.Fatalf("messages[7] = %#v, want list_dir tool summary", resp.Messages[7])
}
assertMessage(8, "assistant", "", "tool visible only")
assertMessage(9, "user", "", "turn4")
assertMessage(10, "assistant", "thought", "tool mixed thought")
if !strings.Contains(resp.Messages[11].Content, "`exec`") {
t.Fatalf("messages[11] = %#v, want exec tool summary", resp.Messages[11])
}
assertMessage(12, "assistant", "", "tool visible and thought")
}
func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) { func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -1056,8 +1290,11 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
if !strings.Contains(resp.Messages[1].Content, wantPreview) { if !strings.Contains(resp.Messages[1].Content, wantPreview) {
t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview) t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview)
} }
if strings.Contains(resp.Messages[1].Content, argsJSON) { wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content) Function: &providers.FunctionCall{Arguments: argsJSON},
}, 20)
if !strings.Contains(resp.Messages[1].Content, wantArgsPreview) {
t.Fatalf("tool summary = %q, want args preview %q", resp.Messages[1].Content, wantArgsPreview)
} }
if !strings.Contains(resp.Messages[1].Content, "`read_file`") { if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
@ -1132,7 +1369,9 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t
t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages))
} }
wantPreview := utils.Truncate(argsJSON, 20) wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
Function: &providers.FunctionCall{Arguments: argsJSON},
}, 20)
if !strings.Contains(resp.Messages[1].Content, "`read_file`") { if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
} }

View file

@ -9,7 +9,6 @@ import (
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
picotools "github.com/sipeed/picoclaw/pkg/tools"
) )
func TestHandleListTools(t *testing.T) { func TestHandleListTools(t *testing.T) {
@ -517,22 +516,12 @@ func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testi
} }
} }
func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) { func TestResolveCurrentWebSearchProvider_PrefersStableDefaultForSogouAndDuckDuckGo(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Tools.Web.Provider = "auto" cfg.Tools.Web.Provider = "auto"
cfg.Tools.Web.Sogou.Enabled = true cfg.Tools.Web.Sogou.Enabled = true
cfg.Tools.Web.DuckDuckGo.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" { if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
} }

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -29,7 +29,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/netbind" "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/api"
"github.com/sipeed/picoclaw/web/backend/dashboardauth" "github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/launcherconfig"
@ -409,7 +408,6 @@ func main() {
if *lang != "" { if *lang != "" {
SetLanguage(*lang) SetLanguage(*lang)
} }
tools.SetPreferredWebSearchLanguage(string(GetLanguage()))
// Resolve config path // Resolve config path
configPath := utils.GetDefaultConfigPath() configPath := utils.GetDefaultConfigPath()

View file

@ -27,13 +27,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"i18next": "^26.0.3", "i18next": "^26.0.7",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.19.1", "jotai": "^2.19.1",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.5", "react": "19.2.5",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"react-i18next": "^17.0.3", "react-i18next": "^17.0.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
@ -65,7 +65,7 @@
"prettier": "^3.8.3", "prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.59.0",
"vite": "^8.0.8" "vite": "^8.0.10"
} }
} }

View file

@ -16,7 +16,7 @@ importers:
version: 3.41.1(react@19.2.5) version: 3.41.1(react@19.2.5)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@8.0.8(@types/node@25.6.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': '@tanstack/react-query':
specifier: ^5.99.0 specifier: ^5.99.0
version: 5.99.0(react@19.2.5) version: 5.99.0(react@19.2.5)
@ -39,8 +39,8 @@ importers:
specifier: ^11.11.1 specifier: ^11.11.1
version: 11.11.1 version: 11.11.1
i18next: i18next:
specifier: ^26.0.3 specifier: ^26.0.7
version: 26.0.3(typescript@5.9.3) version: 26.0.7(typescript@5.9.3)
i18next-browser-languagedetector: i18next-browser-languagedetector:
specifier: ^8.2.1 specifier: ^8.2.1
version: 8.2.1 version: 8.2.1
@ -57,8 +57,8 @@ importers:
specifier: 19.2.5 specifier: 19.2.5
version: 19.2.5(react@19.2.5) version: 19.2.5(react@19.2.5)
react-i18next: react-i18next:
specifier: ^17.0.3 specifier: ^17.0.4
version: 17.0.3(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) 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: react-markdown:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5) version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
@ -104,7 +104,7 @@ importers:
version: 0.5.19(tailwindcss@4.2.2) version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/router-plugin': '@tanstack/router-plugin':
specifier: ^1.164.0 specifier: ^1.164.0
version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) version: 1.167.9(@tanstack/react-router@1.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': '@trivago/prettier-plugin-sort-imports':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(prettier@3.8.3) version: 6.0.2(prettier@3.8.3)
@ -119,10 +119,10 @@ importers:
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.58.2 specifier: ^8.58.2
version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) version: 8.58.2(@typescript-eslint/parser@8.59.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': '@vitejs/plugin-react':
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
eslint: eslint:
specifier: ^10.2.1 specifier: ^10.2.1
version: 10.2.1(jiti@2.6.1) version: 10.2.1(jiti@2.6.1)
@ -148,11 +148,11 @@ importers:
specifier: ~5.9.3 specifier: ~5.9.3
version: 5.9.3 version: 5.9.3
typescript-eslint: typescript-eslint:
specifier: ^8.58.2 specifier: ^8.59.0
version: 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
vite: vite:
specifier: ^8.0.8 specifier: ^8.0.10
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
packages: packages:
@ -299,11 +299,11 @@ packages:
peerDependencies: peerDependencies:
'@noble/ciphers': ^1.0.0 '@noble/ciphers': ^1.0.0
'@emnapi/core@1.9.2': '@emnapi/core@1.10.0':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
'@emnapi/runtime@1.9.2': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@ -608,8 +608,8 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@napi-rs/wasm-runtime@1.1.3': '@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies: peerDependencies:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
@ -650,8 +650,8 @@ packages:
'@open-draft/until@2.1.0': '@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@oxc-project/types@0.124.0': '@oxc-project/types@0.127.0':
resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
'@radix-ui/number@1.1.1': '@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@ -1343,103 +1343,103 @@ packages:
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@rolldown/binding-android-arm64@1.0.0-rc.15': '@rolldown/binding-android-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@rolldown/binding-darwin-arm64@1.0.0-rc.15': '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@rolldown/binding-darwin-x64@1.0.0-rc.15': '@rolldown/binding-darwin-x64@1.0.0-rc.17':
resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.0-rc.15': '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15': '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15': '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.0-rc.15': '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==}
engines: {node: '>=14.0.0'} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32] cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rolldown/pluginutils@1.0.0-rc.15': '@rolldown/pluginutils@1.0.0-rc.17':
resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
'@rolldown/pluginutils@1.0.0-rc.7': '@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
@ -1736,8 +1736,16 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.58.2': '@typescript-eslint/eslint-plugin@8.59.0':
resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} 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} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@ -1749,16 +1757,32 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@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.1.0'
'@typescript-eslint/scope-manager@8.58.2': '@typescript-eslint/scope-manager@8.58.2':
resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@typescript-eslint/tsconfig-utils@8.58.2':
resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' 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': '@typescript-eslint/type-utils@8.58.2':
resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1766,16 +1790,33 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@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.1.0'
'@typescript-eslint/types@8.58.2': '@typescript-eslint/types@8.58.2':
resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@typescript-eslint/typescript-estree@8.58.2':
resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' 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': '@typescript-eslint/utils@8.58.2':
resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -1783,10 +1824,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.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': '@typescript-eslint/visitor-keys@8.58.2':
resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@ -2544,8 +2596,8 @@ packages:
i18next-browser-languagedetector@8.2.1: i18next-browser-languagedetector@8.2.1:
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
i18next@26.0.3: i18next@26.0.7:
resolution: {integrity: sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==} resolution: {integrity: sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ==}
peerDependencies: peerDependencies:
typescript: ^5 || ^6 typescript: ^5 || ^6
peerDependenciesMeta: peerDependenciesMeta:
@ -3233,10 +3285,6 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
postcss@8.5.9:
resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0: powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -3357,8 +3405,8 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.5 react: ^19.2.5
react-i18next@17.0.3: react-i18next@17.0.4:
resolution: {integrity: sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==} resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==}
peerDependencies: peerDependencies:
i18next: '>= 26.0.1' i18next: '>= 26.0.1'
react: '>= 16.8.0' react: '>= 16.8.0'
@ -3474,8 +3522,8 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rolldown@1.0.0-rc.15: rolldown@1.0.0-rc.17:
resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
@ -3736,8 +3784,8 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
typescript-eslint@8.58.2: typescript-eslint@8.59.0:
resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==} resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@ -3872,8 +3920,8 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@8.0.8: vite@8.0.10:
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -4212,13 +4260,13 @@ snapshots:
dependencies: dependencies:
'@noble/ciphers': 1.3.0 '@noble/ciphers': 1.3.0
'@emnapi/core@1.9.2': '@emnapi/core@1.10.0':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.9.2': '@emnapi/runtime@1.10.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
@ -4451,10 +4499,10 @@ snapshots:
outvariant: 1.4.3 outvariant: 1.4.3
strict-event-emitter: 0.5.1 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: dependencies:
'@emnapi/core': 1.9.2 '@emnapi/core': 1.10.0
'@emnapi/runtime': 1.9.2 '@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
@ -4489,7 +4537,7 @@ snapshots:
'@open-draft/until@2.1.0': {} '@open-draft/until@2.1.0': {}
'@oxc-project/types@0.124.0': {} '@oxc-project/types@0.127.0': {}
'@radix-ui/number@1.1.1': {} '@radix-ui/number@1.1.1': {}
@ -5238,56 +5286,56 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@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 optional: true
'@rolldown/binding-darwin-arm64@1.0.0-rc.15': '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-darwin-x64@1.0.0-rc.15': '@rolldown/binding-darwin-x64@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-freebsd-x64@1.0.0-rc.15': '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15': '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15': '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.15': '@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
dependencies: dependencies:
'@emnapi/core': 1.9.2 '@emnapi/core': 1.10.0
'@emnapi/runtime': 1.9.2 '@emnapi/runtime': 1.10.0
'@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)
optional: true optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
optional: true optional: true
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
optional: true optional: true
'@rolldown/pluginutils@1.0.0-rc.15': {} '@rolldown/pluginutils@1.0.0-rc.17': {}
'@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.7': {}
@ -5368,12 +5416,12 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 4.2.2 tailwindcss: 4.2.2
'@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.6.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: dependencies:
'@tailwindcss/node': 4.2.2 '@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2 '@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2 tailwindcss: 4.2.2
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
'@tanstack/history@1.161.6': {} '@tanstack/history@1.161.6': {}
@ -5446,7 +5494,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.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: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@ -5463,7 +5511,7 @@ snapshots:
zod: 3.25.76 zod: 3.25.76
optionalDependencies: optionalDependencies:
'@tanstack/react-router': 1.168.23(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)
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -5558,10 +5606,10 @@ snapshots:
'@types/validate-npm-package-name@4.0.2': {} '@types/validate-npm-package-name@4.0.2': {}
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.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: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.58.2(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/scope-manager': 8.58.2 '@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/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/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
@ -5574,12 +5622,28 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.58.2(eslint@10.2.1(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: dependencies:
'@typescript-eslint/scope-manager': 8.58.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/types': 8.58.2 '@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.59.0
'@typescript-eslint/visitor-keys': 8.58.2 '@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/parser@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.59.0
'@typescript-eslint/types': 8.59.0
'@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.0
debug: 4.4.3 debug: 4.4.3
eslint: 10.2.1(jiti@2.6.1) eslint: 10.2.1(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
@ -5588,8 +5652,17 @@ snapshots:
'@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
'@typescript-eslint/types': 8.58.2 '@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 debug: 4.4.3
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@ -5600,10 +5673,19 @@ snapshots:
'@typescript-eslint/types': 8.58.2 '@typescript-eslint/types': 8.58.2
'@typescript-eslint/visitor-keys': 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)': '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)':
dependencies: dependencies:
typescript: 5.9.3 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)': '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.58.2 '@typescript-eslint/types': 8.58.2
@ -5616,8 +5698,22 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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.58.2': {}
'@typescript-eslint/types@8.59.0': {}
'@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3)
@ -5633,6 +5729,21 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
'@typescript-eslint/types': 8.59.0
'@typescript-eslint/visitor-keys': 8.59.0
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/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)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
@ -5644,17 +5755,33 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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': '@typescript-eslint/visitor-keys@8.58.2':
dependencies: dependencies:
'@typescript-eslint/types': 8.58.2 '@typescript-eslint/types': 8.58.2
eslint-visitor-keys: 5.0.1 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': {} '@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7 '@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
accepts@2.0.0: accepts@2.0.0:
dependencies: dependencies:
@ -6469,9 +6596,7 @@ snapshots:
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
i18next@26.0.3(typescript@5.9.3): i18next@26.0.7(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
@ -7267,12 +7392,6 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
postcss@8.5.9:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {} powershell-utils@0.1.0: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
@ -7386,11 +7505,11 @@ snapshots:
react: 19.2.5 react: 19.2.5
scheduler: 0.27.0 scheduler: 0.27.0
react-i18next@17.0.3(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: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1 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 react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5) use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies: optionalDependencies:
@ -7535,26 +7654,26 @@ snapshots:
reusify@1.1.0: {} reusify@1.1.0: {}
rolldown@1.0.0-rc.15: rolldown@1.0.0-rc.17:
dependencies: dependencies:
'@oxc-project/types': 0.124.0 '@oxc-project/types': 0.127.0
'@rolldown/pluginutils': 1.0.0-rc.15 '@rolldown/pluginutils': 1.0.0-rc.17
optionalDependencies: optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-rc.15 '@rolldown/binding-android-arm64': 1.0.0-rc.17
'@rolldown/binding-darwin-arm64': 1.0.0-rc.15 '@rolldown/binding-darwin-arm64': 1.0.0-rc.17
'@rolldown/binding-darwin-x64': 1.0.0-rc.15 '@rolldown/binding-darwin-x64': 1.0.0-rc.17
'@rolldown/binding-freebsd-x64': 1.0.0-rc.15 '@rolldown/binding-freebsd-x64': 1.0.0-rc.17
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
router@2.2.0: router@2.2.0:
dependencies: dependencies:
@ -7848,12 +7967,12 @@ snapshots:
media-typer: 1.1.0 media-typer: 1.1.0
mime-types: 3.0.2 mime-types: 3.0.2
typescript-eslint@8.58.2(eslint@10.2.1(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: dependencies:
'@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.59.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.58.2(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.58.2(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 10.2.1(jiti@2.6.1) eslint: 10.2.1(jiti@2.6.1)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@ -7985,12 +8104,12 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.0.3 vfile-message: 4.0.3
vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0): vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0):
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
postcss: 8.5.9 postcss: 8.5.10
rolldown: 1.0.0-rc.15 rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16 tinyglobby: 0.2.16
optionalDependencies: optionalDependencies:
'@types/node': 25.6.0 '@types/node': 25.6.0

View file

@ -14,6 +14,7 @@ export interface SessionDetail {
messages: { messages: {
role: "user" | "assistant" role: "user" | "assistant"
content: string content: string
kind?: "normal" | "thought"
media?: string[] media?: string[]
attachments?: { attachments?: {
type?: "image" | "audio" | "video" | "file" type?: "image" | "audio" | "video" | "file"

View file

@ -6,7 +6,6 @@ import {
IconDownload, IconDownload,
IconFileText, IconFileText,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { useAtom } from "jotai"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import ReactMarkdown from "react-markdown" import ReactMarkdown from "react-markdown"
@ -18,7 +17,7 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat" import { formatMessageTime } from "@/hooks/use-pico-chat"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { type ChatAttachment, showThoughtsAtom } from "@/store/chat" import { type ChatAttachment } from "@/store/chat"
interface AssistantMessageProps { interface AssistantMessageProps {
content: string content: string
@ -42,7 +41,7 @@ export function AssistantMessage({
const fileAttachments = attachments.filter( const fileAttachments = attachments.filter(
(attachment) => attachment.type !== "image", (attachment) => attachment.type !== "image",
) )
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom) const [isExpanded, setIsExpanded] = useState(true)
const formattedTimestamp = const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : "" timestamp !== "" ? formatMessageTime(timestamp) : ""

View file

@ -1,4 +1,5 @@
import { IconPlus } from "@tabler/icons-react" import { IconPlus } from "@tabler/icons-react"
import { useAtom } from "jotai"
import { type ChangeEvent, useEffect, useRef, useState } from "react" import { type ChangeEvent, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
@ -15,12 +16,14 @@ import { TypingIndicator } from "@/components/chat/typing-indicator"
import { UserMessage } from "@/components/chat/user-message" import { UserMessage } from "@/components/chat/user-message"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { useChatModels } from "@/hooks/use-chat-models" import { useChatModels } from "@/hooks/use-chat-models"
import { useGateway } from "@/hooks/use-gateway" import { useGateway } from "@/hooks/use-gateway"
import { usePicoChat } from "@/hooks/use-pico-chat" import { usePicoChat } from "@/hooks/use-pico-chat"
import { useSessionHistory } from "@/hooks/use-session-history" import { useSessionHistory } from "@/hooks/use-session-history"
import type { ConnectionState } from "@/store/chat" import type { ConnectionState } from "@/store/chat"
import type { ChatAttachment } from "@/store/chat" import type { ChatAttachment } from "@/store/chat"
import { showThoughtsAtom } from "@/store/chat"
import type { GatewayState } from "@/store/gateway" import type { GatewayState } from "@/store/gateway"
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
@ -109,6 +112,7 @@ export function ChatPage() {
const [hasScrolled, setHasScrolled] = useState(false) const [hasScrolled, setHasScrolled] = useState(false)
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<ChatAttachment[]>([]) const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [showThoughts, setShowThoughts] = useAtom(showThoughtsAtom)
const { const {
messages, messages,
@ -265,6 +269,18 @@ export function ChatPage() {
) )
} }
> >
<div className="hidden items-center gap-2 rounded-lg border border-border/60 px-3 py-1.5 sm:flex">
<span className="text-muted-foreground text-sm">
{t("chat.showThoughts")}
</span>
<Switch
checked={showThoughts}
onCheckedChange={setShowThoughts}
aria-label={t("chat.showThoughts")}
size="sm"
/>
</div>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@ -306,23 +322,29 @@ export function ChatPage() {
/> />
)} )}
{messages.map((msg) => ( {messages.map((msg) => {
<div key={msg.id} className="flex w-full"> if (msg.kind === "thought" && !showThoughts) {
{msg.role === "assistant" ? ( return null
<AssistantMessage }
content={msg.content}
attachments={msg.attachments} return (
isThought={msg.kind === "thought"} <div key={msg.id} className="flex w-full">
timestamp={msg.timestamp} {msg.role === "assistant" ? (
/> <AssistantMessage
) : ( content={msg.content}
<UserMessage attachments={msg.attachments}
content={msg.content} isThought={msg.kind === "thought"}
attachments={msg.attachments} timestamp={msg.timestamp}
/> />
)} ) : (
</div> <UserMessage
))} content={msg.content}
attachments={msg.attachments}
/>
)}
</div>
)
})}
{isTyping && <TypingIndicator />} {isTyping && <TypingIndicator />}
</div> </div>

View file

@ -244,6 +244,7 @@ export function ConfigPage() {
tool_feedback: { tool_feedback: {
enabled: form.toolFeedbackEnabled, enabled: form.toolFeedbackEnabled,
max_args_length: toolFeedbackMaxArgsLength, max_args_length: toolFeedbackMaxArgsLength,
separate_messages: form.toolFeedbackSeparateMessages,
}, },
max_tokens: maxTokens, max_tokens: maxTokens,
context_window: contextWindow, context_window: contextWindow,

View file

@ -113,6 +113,18 @@ export function AgentDefaultsSection({
} }
/> />
{form.toolFeedbackEnabled && (
<SwitchCardField
label={t("pages.config.tool_feedback_separate_messages")}
hint={t("pages.config.tool_feedback_separate_messages_hint")}
layout="setting-row"
checked={form.toolFeedbackSeparateMessages}
onCheckedChange={(checked) =>
onFieldChange("toolFeedbackSeparateMessages", checked)
}
/>
)}
{form.toolFeedbackEnabled && ( {form.toolFeedbackEnabled && (
<Field <Field
label={t("pages.config.tool_feedback_max_args_length")} label={t("pages.config.tool_feedback_max_args_length")}

Some files were not shown because too many files have changed in this diff Show more