Merge branch 'sipeed:main' into main
This commit is contained in:
commit
ed7c570917
106 changed files with 7574 additions and 652 deletions
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal 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
|
||||
160
.github/workflows/nightly.yml
vendored
160
.github/workflows/nightly.yml
vendored
|
|
@ -74,7 +74,10 @@ jobs:
|
|||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKERHUB_USERNAME != ''
|
||||
uses: docker/login-action@v4
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
|
@ -86,6 +89,10 @@ jobs:
|
|||
- name: Create local tag for GoReleaser
|
||||
run: git tag "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Lowercase owner for Docker tags
|
||||
id: repo
|
||||
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
|
|
@ -94,7 +101,7 @@ jobs:
|
|||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
REPO_OWNER: ${{ steps.repo.outputs.owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
|
||||
|
|
@ -144,3 +151,154 @@ jobs:
|
|||
--prerelease \
|
||||
--latest=false \
|
||||
"${ASSETS[@]}"
|
||||
|
||||
build-macos-launcher:
|
||||
name: Build macOS Launcher (${{ matrix.arch_name }})
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goarch: arm64
|
||||
arch_name: arm64
|
||||
- goarch: amd64
|
||||
arch_name: x86_64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web/frontend
|
||||
CI=true pnpm install --frozen-lockfile
|
||||
pnpm build:backend
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date -u +%Y%m%d)
|
||||
SHA=$(git rev-parse --short=8 HEAD)
|
||||
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
|
||||
else
|
||||
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build picoclaw-launcher with CGO
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
GOOS: darwin
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
SDK_PATH=$(xcrun --show-sdk-path)
|
||||
export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0"
|
||||
export CGO_LDFLAGS="-isysroot ${SDK_PATH}"
|
||||
|
||||
go generate ./...
|
||||
go build -tags "goolm,stdjson" \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.Version=${{ steps.version.outputs.version }} \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-o picoclaw-launcher-cgo \
|
||||
./web/backend
|
||||
|
||||
- name: Sign and notarize launcher binary
|
||||
if: env.MACOS_SIGN_P12 != ''
|
||||
env:
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
run: |
|
||||
pip3 install rcodesign
|
||||
|
||||
echo "$MACOS_SIGN_P12" | base64 -d > cert.p12
|
||||
|
||||
rcodesign sign \
|
||||
--p12-file cert.p12 \
|
||||
--p12-password "$MACOS_SIGN_PASSWORD" \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
echo "$MACOS_NOTARY_KEY" > notary-key.p8
|
||||
|
||||
rcodesign notary-submit \
|
||||
--api-key-path notary-key.p8 \
|
||||
--api-issuer "$MACOS_NOTARY_ISSUER_ID" \
|
||||
--wait \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
rm -f cert.p12 notary-key.p8
|
||||
|
||||
- name: Upload launcher artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
path: picoclaw-launcher-cgo
|
||||
retention-days: 1
|
||||
|
||||
patch-macos-archives:
|
||||
name: Patch macOS Archives
|
||||
needs: [nightly, build-macos-launcher]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch_name: arm64
|
||||
- arch_name: x86_64
|
||||
steps:
|
||||
- name: Download launcher artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
|
||||
- name: Patch darwin release archive
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz"
|
||||
|
||||
gh release download nightly \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "${ARCHIVE_NAME}" \
|
||||
--dir ./patch-tmp
|
||||
|
||||
mkdir -p ./patch-extracted
|
||||
tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted
|
||||
|
||||
cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher
|
||||
chmod +x ./patch-extracted/picoclaw-launcher
|
||||
|
||||
tar czf "${ARCHIVE_NAME}" -C ./patch-extracted .
|
||||
|
||||
gh release upload nightly \
|
||||
--repo "${{ github.repository }}" \
|
||||
"${ARCHIVE_NAME}" --clobber
|
||||
|
||||
echo "✅ Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)"
|
||||
|
|
|
|||
151
.github/workflows/release.yml
vendored
151
.github/workflows/release.yml
vendored
|
|
@ -80,7 +80,10 @@ jobs:
|
|||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: env.DOCKERHUB_USERNAME != ''
|
||||
uses: docker/login-action@v4
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
|
@ -89,6 +92,10 @@ jobs:
|
|||
- name: Install zip
|
||||
run: sudo apt-get install -y zip
|
||||
|
||||
- name: Lowercase owner for Docker tags
|
||||
id: repo
|
||||
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
|
|
@ -97,7 +104,7 @@ jobs:
|
|||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
REPO_OWNER: ${{ steps.repo.outputs.owner }}
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||
INCLUDE_ANDROID_BUNDLE: "true"
|
||||
|
|
@ -116,9 +123,149 @@ jobs:
|
|||
--draft=${{ inputs.draft }} \
|
||||
--prerelease=${{ inputs.prerelease }}
|
||||
|
||||
build-macos-launcher:
|
||||
name: Build macOS Launcher (${{ matrix.arch_name }})
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goarch: arm64
|
||||
arch_name: arm64
|
||||
- goarch: amd64
|
||||
arch_name: x86_64
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.33.0
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd web/frontend
|
||||
CI=true pnpm install --frozen-lockfile
|
||||
pnpm build:backend
|
||||
|
||||
- name: Build picoclaw-launcher with CGO
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
GOOS: darwin
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
SDK_PATH=$(xcrun --show-sdk-path)
|
||||
export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0"
|
||||
export CGO_LDFLAGS="-isysroot ${SDK_PATH}"
|
||||
|
||||
go generate ./...
|
||||
go build -tags "goolm,stdjson" \
|
||||
-ldflags "-s -w \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.Version=${{ inputs.tag }} \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \
|
||||
-X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-o picoclaw-launcher-cgo \
|
||||
./web/backend
|
||||
|
||||
- name: Sign and notarize launcher binary
|
||||
if: env.MACOS_SIGN_P12 != ''
|
||||
env:
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
run: |
|
||||
pip3 install rcodesign
|
||||
|
||||
echo "$MACOS_SIGN_P12" | base64 -d > cert.p12
|
||||
|
||||
rcodesign sign \
|
||||
--p12-file cert.p12 \
|
||||
--p12-password "$MACOS_SIGN_PASSWORD" \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
echo "$MACOS_NOTARY_KEY" > notary-key.p8
|
||||
|
||||
rcodesign notary-submit \
|
||||
--api-key-path notary-key.p8 \
|
||||
--api-issuer "$MACOS_NOTARY_ISSUER_ID" \
|
||||
--wait \
|
||||
picoclaw-launcher-cgo
|
||||
|
||||
rm -f cert.p12 notary-key.p8
|
||||
|
||||
- name: Upload launcher artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
path: picoclaw-launcher-cgo
|
||||
retention-days: 1
|
||||
|
||||
patch-macos-archives:
|
||||
name: Patch macOS Archives
|
||||
needs: [release, build-macos-launcher]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch_name: arm64
|
||||
- arch_name: x86_64
|
||||
steps:
|
||||
- name: Download launcher artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-launcher-${{ matrix.arch_name }}
|
||||
|
||||
- name: Patch darwin release archive
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz"
|
||||
|
||||
gh release download "${TAG}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "${ARCHIVE_NAME}" \
|
||||
--dir ./patch-tmp
|
||||
|
||||
mkdir -p ./patch-extracted
|
||||
tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted
|
||||
|
||||
cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher
|
||||
chmod +x ./patch-extracted/picoclaw-launcher
|
||||
|
||||
tar czf "${ARCHIVE_NAME}" -C ./patch-extracted .
|
||||
|
||||
gh release upload "${TAG}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
"${ARCHIVE_NAME}" --clobber
|
||||
|
||||
echo "Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)"
|
||||
|
||||
upload-tos:
|
||||
name: Upload to TOS
|
||||
needs: release
|
||||
needs: [release, patch-macos-archives]
|
||||
if: ${{ inputs.upload_tos }}
|
||||
uses: ./.github/workflows/upload-tos.yml
|
||||
with:
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -55,6 +55,10 @@ dist/
|
|||
|
||||
# Windows Application Icon/Resource
|
||||
*.syso
|
||||
.cache/
|
||||
web/frontend/.pnpm-store/
|
||||
_tmp_*
|
||||
web/frontend/_tmp_*
|
||||
|
||||
# Test telegram integration
|
||||
cmd/telegram/
|
||||
|
|
|
|||
|
|
@ -151,8 +151,8 @@ dockers_v2:
|
|||
ids:
|
||||
- picoclaw
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
- "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
|
||||
- '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
|
||||
tags:
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||
|
|
@ -168,8 +168,8 @@ dockers_v2:
|
|||
- picoclaw-launcher
|
||||
- picoclaw-launcher-tui
|
||||
images:
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
|
||||
- "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
|
||||
- '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
|
||||
tags:
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
|
||||
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||
|
|
@ -224,7 +224,7 @@ nfpms:
|
|||
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
vendor: picoclaw
|
||||
homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
|
||||
homepage: https://github.com/{{ .Env.REPO_OWNER }}/picoclaw
|
||||
maintainer: picoclaw contributors
|
||||
description: picoclaw - a tool for managing and running tasks
|
||||
license: MIT
|
||||
|
|
|
|||
92
Makefile
92
Makefile
|
|
@ -7,19 +7,43 @@ CMD_DIR=cmd/$(BINARY_NAME)
|
|||
MAIN_GO=$(CMD_DIR)/main.go
|
||||
EXT=
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
POWERSHELL=powershell -NoProfile -Command
|
||||
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
|
||||
endif
|
||||
|
||||
# Version
|
||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME=$(shell date +%FT%T%z)
|
||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||
ifeq ($(OS),Windows_NT)
|
||||
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
|
||||
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
|
||||
BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
|
||||
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
|
||||
else
|
||||
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
|
||||
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
|
||||
BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
|
||||
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
|
||||
endif
|
||||
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
|
||||
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
|
||||
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
|
||||
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
|
||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
GO?=go
|
||||
WEB_GO?=$(GO)
|
||||
CGO_ENABLED?=0
|
||||
GO_BUILD_TAGS?=goolm,stdjson
|
||||
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||
GOCACHE?=$(CURDIR)/.cache/go-build
|
||||
GOMODCACHE?=$(CURDIR)/.cache/go-mod
|
||||
GOTOOLCHAIN?=local
|
||||
export CGO_ENABLED
|
||||
export GOCACHE
|
||||
export GOMODCACHE
|
||||
export GOTOOLCHAIN
|
||||
comma:=,
|
||||
empty:=
|
||||
space:=$(empty) $(empty)
|
||||
|
|
@ -73,8 +97,21 @@ BUILTIN_SKILLS_DIR=$(CURDIR)/skills
|
|||
LNCMD=ln -sf
|
||||
|
||||
# OS detection
|
||||
UNAME_S?=$(shell uname -s)
|
||||
UNAME_M?=$(shell uname -m)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
UNAME_S=Windows
|
||||
ifeq ($(WINDOWS_GOARCH_RAW),amd64)
|
||||
UNAME_M=x86_64
|
||||
else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
|
||||
UNAME_M=arm64
|
||||
else ifeq ($(WINDOWS_GOARCH_RAW),386)
|
||||
UNAME_M=x86
|
||||
else
|
||||
UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
|
||||
endif
|
||||
else
|
||||
UNAME_S?=$(shell uname -s)
|
||||
UNAME_M?=$(shell uname -m)
|
||||
endif
|
||||
|
||||
# Platform-specific settings
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
|
|
@ -122,6 +159,18 @@ else
|
|||
|
||||
endif
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
PLATFORM=windows
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH?=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
ARCH?=arm64
|
||||
else
|
||||
ARCH?=$(UNAME_M)
|
||||
endif
|
||||
EXT=.exe
|
||||
endif
|
||||
|
||||
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
|
||||
# Default target
|
||||
|
|
@ -130,21 +179,37 @@ all: build
|
|||
## generate: Run generate
|
||||
generate:
|
||||
@echo "Run generate..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "if (Test-Path -LiteralPath './$(CMD_DIR)/workspace') { Remove-Item -LiteralPath './$(CMD_DIR)/workspace' -Recurse -Force }"
|
||||
else
|
||||
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
|
||||
endif
|
||||
@$(GO) generate ./...
|
||||
@echo "Run generate complete"
|
||||
|
||||
## build: Build the picoclaw binary for current platform
|
||||
build: generate
|
||||
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BINARY_PATH)$(EXT)' -Destination '$(BUILD_DIR)/$(BINARY_NAME)$(EXT)' -Force"
|
||||
else
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)$(EXT)"
|
||||
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
|
||||
endif
|
||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)$(EXT)"
|
||||
|
||||
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||
build-launcher:
|
||||
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||
@$(MAKE) -C web build PLATFORM="$(PLATFORM)" ARCH="$(ARCH)" EXT="$(EXT)" OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" GO_BUILD_TAGS="$(GO_BUILD_TAGS)"
|
||||
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher$(EXT)' -Force"
|
||||
else
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@GOARCH=${ARCH} $(MAKE) -C web build \
|
||||
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
|
||||
|
|
@ -152,6 +217,7 @@ build-launcher:
|
|||
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
|
||||
LDFLAGS='$(LDFLAGS)'
|
||||
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
|
||||
endif
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
|
||||
|
||||
build-launcher-frontend:
|
||||
|
|
@ -160,10 +226,16 @@ build-launcher-frontend:
|
|||
## build-launcher-tui: Build the picoclaw-launcher TUI binary
|
||||
build-launcher-tui:
|
||||
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT) ./cmd/picoclaw-launcher-tui
|
||||
@$(POWERSHELL) "Copy-Item -LiteralPath '$(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH)$(EXT)' -Destination '$(BUILD_DIR)/picoclaw-launcher-tui$(EXT)' -Force"
|
||||
else
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
|
||||
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
|
||||
endif
|
||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui$(EXT)"
|
||||
|
||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||
build-whatsapp-native: generate
|
||||
|
|
@ -290,7 +362,11 @@ uninstall-all:
|
|||
## clean: Remove build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "if (Test-Path -LiteralPath '$(BUILD_DIR)') { Remove-Item -LiteralPath '$(BUILD_DIR)' -Recurse -Force }"
|
||||
else
|
||||
@rm -rf $(BUILD_DIR)
|
||||
endif
|
||||
@echo "Clean complete"
|
||||
|
||||
## vet: Run go vet for static analysis
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -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
|
||||
|
||||
|
|
@ -591,6 +604,11 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message
|
|||
| `picoclaw status` | Show status |
|
||||
| `picoclaw version` | Show version info |
|
||||
| `picoclaw model` | View or switch the default model |
|
||||
| `picoclaw mcp list` | List configured MCP servers |
|
||||
| `picoclaw mcp add ...` | Add or update an MCP server entry |
|
||||
| `picoclaw mcp test` | Probe a configured MCP server |
|
||||
| `picoclaw mcp edit` | Open config for advanced MCP editing |
|
||||
| `picoclaw mcp remove` | Remove an MCP server entry |
|
||||
| `picoclaw cron list` | List all scheduled jobs |
|
||||
| `picoclaw cron add ...` | Add a scheduled job |
|
||||
| `picoclaw cron disable` | Disable a scheduled job |
|
||||
|
|
@ -619,6 +637,7 @@ For detailed guides beyond this README:
|
|||
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
||||
| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
|
||||
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
|
||||
| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI |
|
||||
| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage |
|
||||
| [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration |
|
||||
| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 360 KiB |
384
cmd/picoclaw/internal/cliui/mcp_show.go
Normal file
384
cmd/picoclaw/internal/cliui/mcp_show.go
Normal 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 + "…"
|
||||
}
|
||||
249
cmd/picoclaw/internal/mcp/add.go
Normal file
249
cmd/picoclaw/internal/mcp/add.go
Normal 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
|
||||
}
|
||||
25
cmd/picoclaw/internal/mcp/command.go
Normal file
25
cmd/picoclaw/internal/mcp/command.go
Normal 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
|
||||
}
|
||||
619
cmd/picoclaw/internal/mcp/command_test.go
Normal file
619
cmd/picoclaw/internal/mcp/command_test.go
Normal 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
|
||||
}
|
||||
54
cmd/picoclaw/internal/mcp/edit.go
Normal file
54
cmd/picoclaw/internal/mcp/edit.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
359
cmd/picoclaw/internal/mcp/helpers.go
Normal file
359
cmd/picoclaw/internal/mcp/helpers.go
Normal 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
|
||||
}
|
||||
78
cmd/picoclaw/internal/mcp/list.go
Normal file
78
cmd/picoclaw/internal/mcp/list.go
Normal 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
|
||||
}
|
||||
39
cmd/picoclaw/internal/mcp/remove.go
Normal file
39
cmd/picoclaw/internal/mcp/remove.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
237
cmd/picoclaw/internal/mcp/show.go
Normal file
237
cmd/picoclaw/internal/mcp/show.go
Normal 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
|
||||
}
|
||||
46
cmd/picoclaw/internal/mcp/test.go
Normal file
46
cmd/picoclaw/internal/mcp/test.go
Normal 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
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:generate cp -r ../../../../workspace .
|
||||
//go:generate go run ../../../../scripts/copydir.go "${DOLLAR}{codespace}/workspace" ./workspace
|
||||
//go:embed workspace
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
||||
|
|
@ -87,6 +88,7 @@ picoclaw --no-color status`,
|
|||
gateway.NewGatewayCommand(),
|
||||
status.NewStatusCommand(),
|
||||
cron.NewCronCommand(),
|
||||
mcp.NewMCPCommand(),
|
||||
migrate.NewMigrateCommand(),
|
||||
skills.NewSkillsCommand(),
|
||||
model.NewModelCommand(),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
|||
"auth",
|
||||
"cron",
|
||||
"gateway",
|
||||
"mcp",
|
||||
"migrate",
|
||||
"model",
|
||||
"onboard",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
"split_on_marker": false,
|
||||
"tool_feedback": {
|
||||
"enabled": false,
|
||||
"max_args_length": 300
|
||||
"max_args_length": 300,
|
||||
"separate_messages": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# Remove stale PID file from a previous container run.
|
||||
# After docker kill / OOM / crash the PID file may linger on the bind-mounted
|
||||
# volume and block the next gateway start (the recorded PID could collide with
|
||||
# an unrelated process inside the new container).
|
||||
rm -f "${HOME}/.picoclaw/.picoclaw.pid"
|
||||
|
||||
exec picoclaw gateway "$@"
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ Debug logs are server-side only. If you want the agent to send a visible notific
|
|||
"defaults": {
|
||||
"tool_feedback": {
|
||||
"enabled": true,
|
||||
"max_args_length": 300
|
||||
"max_args_length": 300,
|
||||
"separate_messages": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +86,7 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo
|
|||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `enabled` | bool | `false` | Send a chat notification for each tool call |
|
||||
| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message |
|
||||
| `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification |
|
||||
|
||||
### Environment variables
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -574,6 +587,11 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol
|
|||
| `picoclaw status` | Mostra lo stato |
|
||||
| `picoclaw version` | Mostra le info sulla versione |
|
||||
| `picoclaw model` | Visualizza o cambia il modello predefinito |
|
||||
| `picoclaw mcp list` | Elenca i server MCP configurati |
|
||||
| `picoclaw mcp add ...` | Aggiunge o aggiorna un server MCP |
|
||||
| `picoclaw mcp test` | Verifica la raggiungibilità di un server MCP |
|
||||
| `picoclaw mcp edit` | Apre la config per modifiche MCP avanzate |
|
||||
| `picoclaw mcp remove` | Rimuove un server MCP dalla config |
|
||||
| `picoclaw cron list` | Elenca tutti i job pianificati |
|
||||
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
||||
| `picoclaw cron disable` | Disabilita un job pianificato |
|
||||
|
|
@ -600,6 +618,7 @@ Per guide dettagliate oltre questo README:
|
|||
| [Docker & Avvio Rapido](../guides/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
|
||||
| [App di Chat](../guides/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
|
||||
| [Configurazione](../guides/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
|
||||
| [MCP Server CLI](../reference/mcp-cli.md) | Aggiunta, elenco, test, modifica e rimozione dei server MCP da CLI |
|
||||
| [Provider & Modelli](../guides/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
|
||||
| [Spawn & Task Asincroni](../guides/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
|
||||
| [Hooks](../architecture/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
Reference docs for precise configuration, runtime behavior, and tool semantics.
|
||||
|
||||
- [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills.
|
||||
- [MCP Server CLI](mcp-cli.md): add, list, test, edit, and remove MCP server entries from the command line.
|
||||
- [Scheduled Tasks and Cron Jobs](cron.md): schedule types, delivery modes, command gates, and storage.
|
||||
- [Config Schema Versioning Guide](config-versioning.md): config schema migration and compatibility notes.
|
||||
- [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers.
|
||||
|
|
|
|||
361
docs/reference/mcp-cli.md
Normal file
361
docs/reference/mcp-cli.md
Normal 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
|
||||
|
|
@ -258,6 +258,17 @@ For schedule types, execution modes (`deliver`, agent turn, and command jobs), p
|
|||
|
||||
The MCP tool enables integration with external Model Context Protocol servers.
|
||||
|
||||
If you prefer not to edit JSON manually, PicoClaw also provides an MCP configuration manager CLI:
|
||||
|
||||
- `picoclaw mcp add` — add or update a server (supports `--deferred` / `--no-deferred`)
|
||||
- `picoclaw mcp list` — list all configured servers with status and deferred state
|
||||
- `picoclaw mcp show <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)
|
||||
|
||||
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
|
||||
|
|
|
|||
36
go.mod
36
go.mod
|
|
@ -9,9 +9,9 @@ require (
|
|||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
|
|
@ -23,7 +23,7 @@ require (
|
|||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||
|
|
@ -34,7 +34,7 @@ require (
|
|||
github.com/pion/rtp v1.10.1
|
||||
github.com/pion/webrtc/v3 v3.3.6
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
|
|
@ -55,19 +55,19 @@ require (
|
|||
require (
|
||||
aead.dev/minisign v0.2.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
|
||||
github.com/aws/smithy-go v1.25.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beeper/argo-go v1.1.2 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
|
|
|
|||
74
go.sum
74
go.sum
|
|
@ -23,38 +23,38 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
|
|||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo=
|
||||
github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5 h1:ZGTl4Rxft1uyENAlGESY04hMzE4cLLNUPI7dGw08haw=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5/go.mod h1:jnugA+VgESQGgXuEKK6zVToET/DtODq7LQYpe+BkKT4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
|
||||
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
|
||||
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||
|
|
@ -138,8 +138,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.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
|
|
@ -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.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/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/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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
|
|
|
|||
|
|
@ -135,6 +135,25 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
|||
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
|
||||
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
|
||||
|
||||
for _, agentID := range agentIDs {
|
||||
agent, ok := al.registry.GetAgent(agentID)
|
||||
if !ok || agent.ContextBuilder == nil {
|
||||
continue
|
||||
}
|
||||
if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{
|
||||
serverName: serverName,
|
||||
toolCount: len(conn.Tools),
|
||||
deferred: registerAsHidden,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Failed to register MCP prompt contributor",
|
||||
map[string]any{
|
||||
"agent_id": agentID,
|
||||
"server": serverName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, tool := range conn.Tools {
|
||||
for _, agentID := range agentIDs {
|
||||
agent, ok := al.registry.GetAgent(agentID)
|
||||
|
|
|
|||
|
|
@ -1720,6 +1720,38 @@ func (m *messageToolProvider) GetDefaultModel() string {
|
|||
return "message-tool-model"
|
||||
}
|
||||
|
||||
type reasoningVisibleToolProvider struct {
|
||||
filePath string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *reasoningVisibleToolProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
m.calls++
|
||||
if m.calls == 1 {
|
||||
return &providers.LLMResponse{
|
||||
Content: "I'll inspect that file now.",
|
||||
ReasoningContent: "Read the file before answering.",
|
||||
ToolCalls: []providers.ToolCall{{
|
||||
ID: "call_read_file",
|
||||
Type: "function",
|
||||
Name: "read_file",
|
||||
Arguments: map[string]any{"path": m.filePath},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
return &providers.LLMResponse{Content: "DONE"}, nil
|
||||
}
|
||||
|
||||
func (m *reasoningVisibleToolProvider) GetDefaultModel() string {
|
||||
return "reasoning-visible-tool-model"
|
||||
}
|
||||
|
||||
type artifactThenSendProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
|
@ -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) {
|
||||
response := &providers.LLMResponse{
|
||||
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 {
|
||||
calls int
|
||||
}
|
||||
|
|
@ -3940,6 +4005,12 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) {
|
|||
if !strings.Contains(outbound.Content, "check tool feedback") {
|
||||
t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, "\"path\":") {
|
||||
t.Fatalf("tool feedback content = %q, want serialized tool arguments", outbound.Content)
|
||||
}
|
||||
if !strings.Contains(outbound.Content, heartbeatFile) {
|
||||
t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content)
|
||||
}
|
||||
if strings.Contains(outbound.Content, "Previous turn explanation") {
|
||||
t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content)
|
||||
}
|
||||
|
|
@ -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) {
|
||||
tmpDir := t.TempDir()
|
||||
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") {
|
||||
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") {
|
||||
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])
|
||||
}
|
||||
if outputs[1] != "final model text" {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -170,6 +172,18 @@ func toolFeedbackExplanationFromMessages(messages []providers.Message) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func toolFeedbackArgsPreview(args map[string]any, maxLen int) string {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
|
||||
argsJSON, err := json.MarshalIndent(args, "", " ")
|
||||
if err != nil {
|
||||
return utils.Truncate(fmt.Sprintf("%v", args), maxLen)
|
||||
}
|
||||
return utils.Truncate(string(argsJSON), maxLen)
|
||||
}
|
||||
|
||||
func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool {
|
||||
if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback {
|
||||
return false
|
||||
|
|
@ -465,17 +479,28 @@ func sideQuestionResponseContent(response *providers.LLMResponse) string {
|
|||
if response == nil {
|
||||
return ""
|
||||
}
|
||||
if response.Content != "" {
|
||||
if strings.TrimSpace(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 {
|
||||
clone := make(map[string]any, len(opts))
|
||||
for k, v := range opts {
|
||||
clone[k] = v
|
||||
}
|
||||
maps.Copy(clone, opts)
|
||||
return clone
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
|
@ -21,12 +22,11 @@ import (
|
|||
)
|
||||
|
||||
type ContextBuilder struct {
|
||||
workspace string
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
toolDiscoveryBM25 bool
|
||||
toolDiscoveryRegex bool
|
||||
splitOnMarker bool
|
||||
workspace string
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
splitOnMarker bool
|
||||
promptRegistry *PromptRegistry
|
||||
|
||||
// Cache for system prompt to avoid rebuilding on every call.
|
||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||
|
|
@ -48,8 +48,16 @@ type ContextBuilder struct {
|
|||
}
|
||||
|
||||
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
||||
cb.toolDiscoveryBM25 = useBM25
|
||||
cb.toolDiscoveryRegex = useRegex
|
||||
if useBM25 || useRegex {
|
||||
if err := cb.RegisterPromptContributor(toolDiscoveryPromptContributor{
|
||||
useBM25: useBM25,
|
||||
useRegex: useRegex,
|
||||
}); err != nil {
|
||||
logger.WarnCF("agent", "Failed to register tool discovery prompt contributor", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return cb
|
||||
}
|
||||
|
||||
|
|
@ -73,15 +81,38 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
|||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||
|
||||
return &ContextBuilder{
|
||||
workspace: workspace,
|
||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||
memory: NewMemoryStore(workspace),
|
||||
workspace: workspace,
|
||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||
memory: NewMemoryStore(workspace),
|
||||
promptRegistry: NewPromptRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) RegisterPromptSource(desc PromptSourceDescriptor) error {
|
||||
err := cb.promptRegistryOrDefault().RegisterSource(desc)
|
||||
if err == nil {
|
||||
cb.InvalidateCache()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) RegisterPromptContributor(contributor PromptContributor) error {
|
||||
err := cb.promptRegistryOrDefault().RegisterContributor(contributor)
|
||||
if err == nil {
|
||||
cb.InvalidateCache()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) promptRegistryOrDefault() *PromptRegistry {
|
||||
if cb.promptRegistry == nil {
|
||||
cb.promptRegistry = NewPromptRegistry()
|
||||
}
|
||||
return cb.promptRegistry
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) getIdentity() string {
|
||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||
toolDiscovery := cb.getDiscoveryRule()
|
||||
version := config.FormatVersion()
|
||||
|
||||
return fmt.Sprintf(
|
||||
|
|
@ -103,22 +134,20 @@ Your workspace is at: %s
|
|||
|
||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
||||
|
||||
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
|
||||
|
||||
%s`,
|
||||
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
||||
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
|
||||
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) getDiscoveryRule() string {
|
||||
if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
|
||||
func formatToolDiscoveryRule(useBM25, useRegex bool) string {
|
||||
if !useBM25 && !useRegex {
|
||||
return ""
|
||||
}
|
||||
|
||||
var toolNames []string
|
||||
if cb.toolDiscoveryBM25 {
|
||||
if useBM25 {
|
||||
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
|
||||
}
|
||||
if cb.toolDiscoveryRegex {
|
||||
if useRegex {
|
||||
toolNames = append(toolNames, `"tool_search_tool_regex"`)
|
||||
}
|
||||
|
||||
|
|
@ -129,43 +158,103 @@ func (cb *ContextBuilder) getDiscoveryRule() string {
|
|||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||
parts := []string{}
|
||||
return renderPromptPartsLegacy(cb.BuildSystemPromptParts())
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildSystemPromptParts() []PromptPart {
|
||||
stack := NewPromptStack(cb.promptRegistryOrDefault())
|
||||
add := func(part PromptPart) {
|
||||
if err := stack.Add(part); err != nil {
|
||||
logger.WarnCF("agent", "Skipping invalid prompt part", map[string]any{
|
||||
"id": part.ID,
|
||||
"layer": part.Layer,
|
||||
"slot": part.Slot,
|
||||
"source": part.Source.ID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Core identity section
|
||||
parts = append(parts, cb.getIdentity())
|
||||
add(PromptPart{
|
||||
ID: "kernel.identity",
|
||||
Layer: PromptLayerKernel,
|
||||
Slot: PromptSlotIdentity,
|
||||
Source: PromptSource{ID: PromptSourceKernel, Name: "identity"},
|
||||
Title: "picoclaw identity",
|
||||
Content: cb.getIdentity(),
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
})
|
||||
|
||||
// Bootstrap files
|
||||
bootstrapContent := cb.LoadBootstrapFiles()
|
||||
if bootstrapContent != "" {
|
||||
parts = append(parts, bootstrapContent)
|
||||
add(PromptPart{
|
||||
ID: "instruction.workspace",
|
||||
Layer: PromptLayerInstruction,
|
||||
Slot: PromptSlotWorkspace,
|
||||
Source: PromptSource{ID: PromptSourceWorkspace, Name: "workspace"},
|
||||
Title: "workspace instructions",
|
||||
Content: bootstrapContent,
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
})
|
||||
}
|
||||
|
||||
// Skills - show summary, AI can read full content with read_file tool
|
||||
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
||||
if skillsSummary != "" {
|
||||
parts = append(parts, fmt.Sprintf(`# Skills
|
||||
add(PromptPart{
|
||||
ID: "capability.skill_catalog",
|
||||
Layer: PromptLayerCapability,
|
||||
Slot: PromptSlotSkillCatalog,
|
||||
Source: PromptSource{ID: PromptSourceSkillCatalog, Name: "skill:index"},
|
||||
Title: "skill catalog",
|
||||
Content: fmt.Sprintf(`# Skills
|
||||
|
||||
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
||||
|
||||
%s`, skillsSummary))
|
||||
%s`, skillsSummary),
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
})
|
||||
}
|
||||
|
||||
// Memory context
|
||||
memoryContext := cb.memory.GetMemoryContext()
|
||||
if memoryContext != "" {
|
||||
parts = append(parts, "# Memory\n\n"+memoryContext)
|
||||
add(PromptPart{
|
||||
ID: "context.memory",
|
||||
Layer: PromptLayerContext,
|
||||
Slot: PromptSlotMemory,
|
||||
Source: PromptSource{ID: PromptSourceMemory, Name: "memory:workspace"},
|
||||
Title: "memory",
|
||||
Content: "# Memory\n\n" + memoryContext,
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
})
|
||||
}
|
||||
|
||||
// Multi-Message Sending (if enabled)
|
||||
if cb.splitOnMarker {
|
||||
parts = append(parts, `# MULTI-MESSAGE OUTPUT
|
||||
add(PromptPart{
|
||||
ID: "context.output_policy.split_on_marker",
|
||||
Layer: PromptLayerContext,
|
||||
Slot: PromptSlotOutput,
|
||||
Source: PromptSource{ID: PromptSourceOutputPolicy, Name: "split_on_marker"},
|
||||
Title: "multi-message output policy",
|
||||
Content: `# MULTI-MESSAGE OUTPUT
|
||||
You MUST frequently use <|[SPLIT]|> to break your responses into multiple short messages. NEVER output a single long wall of text. Actively split distinct concepts or parts. Example: Message part 1<|[SPLIT]|>Message part 2<|[SPLIT]|>Message part 3
|
||||
|
||||
Each part separated by the marker will be sent as an independent message.`)
|
||||
Each part separated by the marker will be sent as an independent message.`,
|
||||
Stable: true,
|
||||
Cache: PromptCacheEphemeral,
|
||||
})
|
||||
}
|
||||
|
||||
// Join with "---" separator
|
||||
return strings.Join(parts, "\n\n---\n\n")
|
||||
stack.Seal()
|
||||
return stack.Parts()
|
||||
}
|
||||
|
||||
// BuildSystemPromptWithCache returns the cached system prompt if available
|
||||
|
|
@ -230,6 +319,19 @@ func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []st
|
|||
totalChars += 7 // separator \n\n---\n\n
|
||||
}
|
||||
|
||||
if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), PromptBuildRequest{
|
||||
Summary: summary,
|
||||
ActiveSkills: append([]string(nil), activeSkills...),
|
||||
}); err == nil {
|
||||
for _, part := range contributedParts {
|
||||
if strings.TrimSpace(part.Content) == "" {
|
||||
continue
|
||||
}
|
||||
totalChars += utf8.RuneCountInString(part.Content)
|
||||
totalChars += 7 // separator
|
||||
}
|
||||
}
|
||||
|
||||
if summary != "" {
|
||||
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
|
||||
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
|
||||
|
|
@ -548,6 +650,20 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
channel, chatID, senderID, senderDisplayName string,
|
||||
activeSkills ...string,
|
||||
) []providers.Message {
|
||||
return cb.BuildMessagesFromPrompt(PromptBuildRequest{
|
||||
History: history,
|
||||
Summary: summary,
|
||||
CurrentMessage: currentMessage,
|
||||
Media: media,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
SenderID: senderID,
|
||||
SenderDisplayName: senderDisplayName,
|
||||
ActiveSkills: append([]string(nil), activeSkills...),
|
||||
})
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessagesFromPrompt(req PromptBuildRequest) []providers.Message {
|
||||
messages := []providers.Message{}
|
||||
|
||||
// The static part (identity, bootstrap, skills, memory) is cached locally to
|
||||
|
|
@ -562,7 +678,7 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||
|
||||
// Build short dynamic context (time, runtime, session) — changes per request
|
||||
dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName)
|
||||
dynamicCtx := cb.buildDynamicContext(req.Channel, req.ChatID, req.SenderID, req.SenderDisplayName)
|
||||
|
||||
// Compose a single system message: static (cached) + dynamic + optional summary.
|
||||
// Keeping all system content in one message ensures every provider adapter can
|
||||
|
|
@ -573,25 +689,77 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
// cache-aware adapters (Anthropic) can set per-block cache_control.
|
||||
// The static block is marked "ephemeral" — its prefix hash is stable
|
||||
// across requests, enabling LLM-side KV cache reuse.
|
||||
stringParts := []string{staticPrompt, dynamicCtx}
|
||||
stringParts := []string{staticPrompt}
|
||||
|
||||
contentBlocks := []providers.ContentBlock{
|
||||
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
|
||||
{Type: "text", Text: dynamicCtx},
|
||||
promptContentBlock(PromptPart{
|
||||
ID: "kernel.static",
|
||||
Layer: PromptLayerKernel,
|
||||
Slot: PromptSlotIdentity,
|
||||
Source: PromptSource{ID: PromptSourceKernel, Name: "static"},
|
||||
Content: staticPrompt,
|
||||
}, &providers.CacheControl{Type: "ephemeral"}),
|
||||
}
|
||||
|
||||
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
|
||||
stringParts = append(stringParts, skillsText)
|
||||
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText})
|
||||
promptParts := append([]PromptPart(nil), req.Overlays...)
|
||||
promptParts = append(promptParts, cb.buildActiveSkillsPromptParts(req.ActiveSkills)...)
|
||||
if contributedParts, err := cb.promptRegistryOrDefault().Collect(context.Background(), req); err != nil {
|
||||
logger.WarnCF("agent", "Prompt contributor collection failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
promptParts = append(promptParts, contributedParts...)
|
||||
}
|
||||
|
||||
if summary != "" {
|
||||
summaryText := fmt.Sprintf(
|
||||
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
||||
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
|
||||
summary)
|
||||
stringParts = append(stringParts, summaryText)
|
||||
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
|
||||
if len(promptParts) > 0 {
|
||||
for _, overlay := range sortPromptParts(promptParts) {
|
||||
if strings.TrimSpace(overlay.Content) == "" {
|
||||
continue
|
||||
}
|
||||
if err := cb.promptRegistryOrDefault().ValidatePart(overlay); err != nil {
|
||||
logger.WarnCF("agent", "Skipping invalid prompt overlay", map[string]any{
|
||||
"id": overlay.ID,
|
||||
"layer": overlay.Layer,
|
||||
"slot": overlay.Slot,
|
||||
"source": overlay.Source.ID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
stringParts = append(stringParts, overlay.Content)
|
||||
contentBlocks = append(contentBlocks, promptContentBlock(overlay, nil))
|
||||
}
|
||||
}
|
||||
|
||||
runtimePart := PromptPart{
|
||||
ID: "context.runtime",
|
||||
Layer: PromptLayerContext,
|
||||
Slot: PromptSlotRuntime,
|
||||
Source: PromptSource{ID: PromptSourceRuntime, Name: "runtime"},
|
||||
Title: "runtime context",
|
||||
Content: dynamicCtx,
|
||||
Stable: false,
|
||||
Cache: PromptCacheNone,
|
||||
}
|
||||
stringParts = append(stringParts, dynamicCtx)
|
||||
contentBlocks = append(contentBlocks, promptContentBlock(runtimePart, nil))
|
||||
|
||||
if req.Summary != "" {
|
||||
summaryPart := PromptPart{
|
||||
ID: "context.summary",
|
||||
Layer: PromptLayerContext,
|
||||
Slot: PromptSlotSummary,
|
||||
Source: PromptSource{ID: PromptSourceSummary, Name: "context.summary"},
|
||||
Title: "context summary",
|
||||
Content: fmt.Sprintf(
|
||||
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
||||
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
|
||||
req.Summary),
|
||||
Stable: false,
|
||||
Cache: PromptCacheNone,
|
||||
}
|
||||
stringParts = append(stringParts, summaryPart.Content)
|
||||
contentBlocks = append(contentBlocks, promptContentBlock(summaryPart, nil))
|
||||
}
|
||||
|
||||
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
||||
|
|
@ -608,7 +776,8 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
"static_chars": len(staticPrompt),
|
||||
"dynamic_chars": len(dynamicCtx),
|
||||
"total_chars": len(fullSystemPrompt),
|
||||
"has_summary": summary != "",
|
||||
"has_summary": req.Summary != "",
|
||||
"overlays": len(req.Overlays),
|
||||
"cached": isCached,
|
||||
})
|
||||
|
||||
|
|
@ -619,7 +788,7 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
"preview": preview,
|
||||
})
|
||||
|
||||
history = sanitizeHistoryForProvider(history)
|
||||
history := sanitizeHistoryForProvider(req.History)
|
||||
|
||||
// Single system message containing all context — compatible with all providers.
|
||||
// SystemParts enables cache-aware adapters to set per-block cache_control;
|
||||
|
|
@ -636,15 +805,8 @@ func (cb *ContextBuilder) BuildMessages(
|
|||
// Add current user message. Media-only turns must still be preserved so
|
||||
// multimodal providers receive the uploaded image even when the user sends
|
||||
// no accompanying text.
|
||||
if strings.TrimSpace(currentMessage) != "" || len(media) > 0 {
|
||||
msg := providers.Message{
|
||||
Role: "user",
|
||||
Content: currentMessage,
|
||||
}
|
||||
if len(media) > 0 {
|
||||
msg.Media = append([]string(nil), media...)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
if strings.TrimSpace(req.CurrentMessage) != "" || len(req.Media) > 0 {
|
||||
messages = append(messages, userPromptMessage(req.CurrentMessage, req.Media))
|
||||
}
|
||||
|
||||
return messages
|
||||
|
|
@ -870,6 +1032,26 @@ The following skills are active for this request. Follow them when relevant.
|
|||
%s`, content)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {
|
||||
skillsText := cb.buildActiveSkillsContext(skillNames)
|
||||
if strings.TrimSpace(skillsText) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []PromptPart{
|
||||
{
|
||||
ID: "capability.active_skills",
|
||||
Layer: PromptLayerCapability,
|
||||
Slot: PromptSlotActiveSkill,
|
||||
Source: PromptSource{ID: PromptSourceActiveSkills, Name: "skill:active"},
|
||||
Title: "active skills",
|
||||
Content: skillsText,
|
||||
Stable: false,
|
||||
Cache: PromptCacheNone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) ListSkillNames() []string {
|
||||
if cb.skillsLoader == nil {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -325,6 +326,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM
|
|||
switch decision.normalizedAction() {
|
||||
case HookActionContinue, HookActionModify:
|
||||
if next != nil {
|
||||
next = hm.applyBeforeLLMControls(reg.Name, current, next)
|
||||
current = next
|
||||
}
|
||||
case HookActionAbortTurn, HookActionHardAbort:
|
||||
|
|
@ -367,6 +369,84 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL
|
|||
return current, HookDecision{Action: HookActionContinue}
|
||||
}
|
||||
|
||||
func (hm *HookManager) applyBeforeLLMControls(
|
||||
hookName string,
|
||||
current *LLMHookRequest,
|
||||
next *LLMHookRequest,
|
||||
) *LLMHookRequest {
|
||||
if next == nil || current == nil {
|
||||
return next
|
||||
}
|
||||
if !llmHookSystemMessagesUnchanged(current.Messages, next.Messages) {
|
||||
logger.WarnCF("hooks", "Hook attempted to modify system prompt; preserving original messages", map[string]any{
|
||||
"hook": hookName,
|
||||
})
|
||||
next.Messages = cloneProviderMessages(current.Messages)
|
||||
}
|
||||
if !llmHookToolDefinitionsUnchanged(current.Tools, next.Tools) {
|
||||
logger.WarnCF("hooks", "Hook attempted to modify tool definitions; preserving original tools", map[string]any{
|
||||
"hook": hookName,
|
||||
})
|
||||
next.Tools = cloneToolDefinitions(current.Tools)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func llmHookSystemMessagesUnchanged(before, after []providers.Message) bool {
|
||||
beforeSystem := systemMessageFingerprints(before)
|
||||
afterSystem := systemMessageFingerprints(after)
|
||||
return reflect.DeepEqual(beforeSystem, afterSystem)
|
||||
}
|
||||
|
||||
type systemMessageFingerprint struct {
|
||||
Index int
|
||||
Message providers.Message
|
||||
}
|
||||
|
||||
func systemMessageFingerprints(messages []providers.Message) []systemMessageFingerprint {
|
||||
var fingerprints []systemMessageFingerprint
|
||||
for i, msg := range messages {
|
||||
if msg.Role != "system" {
|
||||
continue
|
||||
}
|
||||
msg = providerVisibleMessage(msg)
|
||||
fingerprints = append(fingerprints, systemMessageFingerprint{
|
||||
Index: i,
|
||||
Message: cloneProviderMessages([]providers.Message{msg})[0],
|
||||
})
|
||||
}
|
||||
return fingerprints
|
||||
}
|
||||
|
||||
func llmHookToolDefinitionsUnchanged(before, after []providers.ToolDefinition) bool {
|
||||
return reflect.DeepEqual(providerVisibleToolDefinitions(before), providerVisibleToolDefinitions(after))
|
||||
}
|
||||
|
||||
func providerVisibleMessage(msg providers.Message) providers.Message {
|
||||
msg.PromptLayer = ""
|
||||
msg.PromptSlot = ""
|
||||
msg.PromptSource = ""
|
||||
if len(msg.SystemParts) > 0 {
|
||||
msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...)
|
||||
for i := range msg.SystemParts {
|
||||
msg.SystemParts[i].PromptLayer = ""
|
||||
msg.SystemParts[i].PromptSlot = ""
|
||||
msg.SystemParts[i].PromptSource = ""
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func providerVisibleToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition {
|
||||
cloned := cloneToolDefinitions(defs)
|
||||
for i := range cloned {
|
||||
cloned[i].PromptLayer = ""
|
||||
cloned[i].PromptSlot = ""
|
||||
cloned[i].PromptSource = ""
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (hm *HookManager) BeforeTool(
|
||||
ctx context.Context,
|
||||
call *ToolCallHookRequest,
|
||||
|
|
@ -788,7 +868,7 @@ func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse {
|
|||
|
||||
func cloneStringAnyMap(src map[string]any) map[string]any {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(src))
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
|
@ -149,6 +150,268 @@ func (h *llmObserverHook) AfterLLM(
|
|||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
type llmSystemRewriteHook struct{}
|
||||
|
||||
func (h *llmSystemRewriteHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, error) {
|
||||
next := req.Clone()
|
||||
next.Model = "changed-model"
|
||||
next.Messages[0].Content = "rewritten system"
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func (h *llmSystemRewriteHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, error) {
|
||||
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
type llmUserAppendHook struct{}
|
||||
|
||||
func (h *llmUserAppendHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, error) {
|
||||
next := req.Clone()
|
||||
next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "extra user context"})
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func (h *llmUserAppendHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, error) {
|
||||
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
type llmJSONRoundTripUserAppendHook struct{}
|
||||
|
||||
type jsonRoundTripLLMHookRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []providers.Message `json:"messages,omitempty"`
|
||||
Tools []providers.ToolDefinition `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
func (h *llmJSONRoundTripUserAppendHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, error) {
|
||||
payload := jsonRoundTripLLMHookRequest{
|
||||
Model: req.Model,
|
||||
Messages: req.Messages,
|
||||
Tools: req.Tools,
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, HookDecision{}, err
|
||||
}
|
||||
var decoded jsonRoundTripLLMHookRequest
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return nil, HookDecision{}, err
|
||||
}
|
||||
next := req.Clone()
|
||||
next.Model = decoded.Model
|
||||
next.Messages = decoded.Messages
|
||||
next.Tools = decoded.Tools
|
||||
next.Messages = append(next.Messages, providers.Message{Role: "user", Content: "json extra user context"})
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func (h *llmJSONRoundTripUserAppendHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, error) {
|
||||
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
type llmToolRewriteHook struct{}
|
||||
|
||||
func (h *llmToolRewriteHook) BeforeLLM(
|
||||
ctx context.Context,
|
||||
req *LLMHookRequest,
|
||||
) (*LLMHookRequest, HookDecision, error) {
|
||||
next := req.Clone()
|
||||
next.Model = "changed-model"
|
||||
next.Tools[0].Function.Description = "rewritten tool"
|
||||
next.Tools = append(next.Tools, providers.ToolDefinition{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "hook_tool",
|
||||
Description: "hook tool",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
PromptLayer: string(PromptLayerCapability),
|
||||
PromptSlot: string(PromptSlotTooling),
|
||||
PromptSource: "hook:test",
|
||||
})
|
||||
return next, HookDecision{Action: HookActionModify}, nil
|
||||
}
|
||||
|
||||
func (h *llmToolRewriteHook) AfterLLM(
|
||||
ctx context.Context,
|
||||
resp *LLMHookResponse,
|
||||
) (*LLMHookResponse, HookDecision, error) {
|
||||
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
|
||||
}
|
||||
|
||||
func TestHookManager_BeforeLLMControlsSystemPromptMutation(t *testing.T) {
|
||||
hm := NewHookManager(nil)
|
||||
if err := hm.Mount(NamedHook("rewrite-system", &llmSystemRewriteHook{})); err != nil {
|
||||
t.Fatalf("Mount() error = %v", err)
|
||||
}
|
||||
|
||||
req := &LLMHookRequest{
|
||||
Model: "original-model",
|
||||
Messages: []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "original system",
|
||||
SystemParts: []providers.ContentBlock{
|
||||
{Type: "text", Text: "original system"},
|
||||
},
|
||||
},
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
got, decision := hm.BeforeLLM(context.Background(), req)
|
||||
if decision.normalizedAction() != HookActionContinue {
|
||||
t.Fatalf("decision = %v, want continue", decision)
|
||||
}
|
||||
if got.Model != "changed-model" {
|
||||
t.Fatalf("model = %q, want changed-model", got.Model)
|
||||
}
|
||||
if got.Messages[0].Content != "original system" {
|
||||
t.Fatalf("system content = %q, want original system", got.Messages[0].Content)
|
||||
}
|
||||
if got.Messages[1].Content != "hello" {
|
||||
t.Fatalf("user content = %q, want hello", got.Messages[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookManager_BeforeLLMAllowsNonSystemMessageMutation(t *testing.T) {
|
||||
hm := NewHookManager(nil)
|
||||
if err := hm.Mount(NamedHook("append-user", &llmUserAppendHook{})); err != nil {
|
||||
t.Fatalf("Mount() error = %v", err)
|
||||
}
|
||||
|
||||
req := &LLMHookRequest{
|
||||
Model: "model",
|
||||
Messages: []providers.Message{
|
||||
{Role: "system", Content: "system"},
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
got, _ := hm.BeforeLLM(context.Background(), req)
|
||||
if len(got.Messages) != 3 {
|
||||
t.Fatalf("messages len = %d, want 3", len(got.Messages))
|
||||
}
|
||||
if got.Messages[2].Role != "user" || got.Messages[2].Content != "extra user context" {
|
||||
t.Fatalf("appended message = %#v, want extra user context", got.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookManager_BeforeLLMAllowsJSONRoundTripNonSystemMessageMutation(t *testing.T) {
|
||||
hm := NewHookManager(nil)
|
||||
if err := hm.Mount(NamedHook("json-append-user", &llmJSONRoundTripUserAppendHook{})); err != nil {
|
||||
t.Fatalf("Mount() error = %v", err)
|
||||
}
|
||||
|
||||
req := &LLMHookRequest{
|
||||
Model: "model",
|
||||
Messages: []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "system",
|
||||
PromptLayer: string(PromptLayerKernel),
|
||||
PromptSlot: string(PromptSlotIdentity),
|
||||
PromptSource: string(PromptSourceKernel),
|
||||
SystemParts: []providers.ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: "system",
|
||||
CacheControl: &providers.CacheControl{Type: "ephemeral"},
|
||||
PromptLayer: string(PromptLayerKernel),
|
||||
PromptSlot: string(PromptSlotIdentity),
|
||||
PromptSource: string(PromptSourceKernel),
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
Tools: []providers.ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "mcp_github_create_issue",
|
||||
Description: "create issue",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
PromptLayer: string(PromptLayerCapability),
|
||||
PromptSlot: string(PromptSlotMCP),
|
||||
PromptSource: "mcp:github",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, _ := hm.BeforeLLM(context.Background(), req)
|
||||
if len(got.Messages) != 3 {
|
||||
t.Fatalf("messages len = %d, want 3", len(got.Messages))
|
||||
}
|
||||
if got.Messages[2].Role != "user" || got.Messages[2].Content != "json extra user context" {
|
||||
t.Fatalf("appended message = %#v, want json extra user context", got.Messages[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookManager_BeforeLLMControlsToolDefinitionMutation(t *testing.T) {
|
||||
hm := NewHookManager(nil)
|
||||
if err := hm.Mount(NamedHook("rewrite-tool", &llmToolRewriteHook{})); err != nil {
|
||||
t.Fatalf("Mount() error = %v", err)
|
||||
}
|
||||
|
||||
req := &LLMHookRequest{
|
||||
Model: "original-model",
|
||||
Messages: []providers.Message{
|
||||
{Role: "system", Content: "system"},
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
Tools: []providers.ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: "mcp_github_create_issue",
|
||||
Description: "create issue",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
PromptLayer: string(PromptLayerCapability),
|
||||
PromptSlot: string(PromptSlotMCP),
|
||||
PromptSource: "mcp:github",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, decision := hm.BeforeLLM(context.Background(), req)
|
||||
if decision.normalizedAction() != HookActionContinue {
|
||||
t.Fatalf("decision = %v, want continue", decision)
|
||||
}
|
||||
if got.Model != "changed-model" {
|
||||
t.Fatalf("model = %q, want changed-model", got.Model)
|
||||
}
|
||||
if len(got.Tools) != 1 {
|
||||
t.Fatalf("tools len = %d, want original 1", len(got.Tools))
|
||||
}
|
||||
if got.Tools[0].Function.Description != "create issue" {
|
||||
t.Fatalf("tool description = %q, want original", got.Tools[0].Function.Description)
|
||||
}
|
||||
if got.Tools[0].PromptSource != "mcp:github" || got.Tools[0].PromptSlot != string(PromptSlotMCP) {
|
||||
t.Fatalf("tool prompt metadata = %#v, want original mcp metadata", got.Tools[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
|
||||
provider := &llmHookTestProvider{}
|
||||
al, agent, cleanup := newHookTestLoop(t, provider)
|
||||
|
|
@ -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 {
|
||||
var result []Event
|
||||
for _, evt := range events {
|
||||
|
|
|
|||
|
|
@ -81,13 +81,18 @@ toolLoop:
|
|||
)
|
||||
|
||||
if shouldPublishToolFeedback(al.cfg, ts) {
|
||||
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
|
||||
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
|
||||
exec.response,
|
||||
tc,
|
||||
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)
|
||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||
fbCancel()
|
||||
|
|
@ -260,7 +265,7 @@ toolLoop:
|
|||
case result, ok := <-ts.pendingResults:
|
||||
if ok && result != nil && result.ForLLM != "" {
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||
msg := subTurnResultPromptMessage(content)
|
||||
messages = append(messages, msg)
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||
}
|
||||
|
|
@ -358,13 +363,18 @@ toolLoop:
|
|||
)
|
||||
|
||||
if shouldPublishToolFeedback(al.cfg, ts) {
|
||||
toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
|
||||
toolFeedbackExplanation := toolFeedbackExplanationForToolCall(
|
||||
exec.response,
|
||||
tc,
|
||||
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)
|
||||
_ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback))
|
||||
fbCancel()
|
||||
|
|
@ -631,7 +641,7 @@ toolLoop:
|
|||
case result, ok := <-ts.pendingResults:
|
||||
if ok && result != nil && result.ForLLM != "" {
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||
msg := subTurnResultPromptMessage(content)
|
||||
messages = append(messages, msg)
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,12 @@ func (p *Pipeline) Finalize(
|
|||
ts.setPhase(TurnPhaseFinalizing)
|
||||
ts.setFinalContent(finalContent)
|
||||
if !ts.opts.NoHistory {
|
||||
finalMsg := providers.Message{Role: "assistant", Content: finalContent}
|
||||
ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content)
|
||||
finalMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: finalContent,
|
||||
ReasoningContent: responseReasoningContent(exec.response),
|
||||
}
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, finalMsg)
|
||||
ts.recordPersistedMessage(finalMsg)
|
||||
ts.ingestMessage(turnCtx, al, finalMsg)
|
||||
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {
|
||||
|
|
|
|||
|
|
@ -319,10 +319,8 @@ func (p *Pipeline) CallLLM(
|
|||
exec.history = asmResp.History
|
||||
exec.summary = asmResp.Summary
|
||||
}
|
||||
exec.messages = ts.agent.ContextBuilder.BuildMessages(
|
||||
exec.history, exec.summary, "",
|
||||
nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
||||
promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil),
|
||||
)
|
||||
exec.callMessages = exec.messages
|
||||
if exec.gracefulTerminal {
|
||||
|
|
@ -384,10 +382,7 @@ func (p *Pipeline) CallLLM(
|
|||
}
|
||||
}
|
||||
|
||||
reasoningContent := exec.response.Reasoning
|
||||
if reasoningContent == "" {
|
||||
reasoningContent = exec.response.ReasoningContent
|
||||
}
|
||||
reasoningContent := responseReasoningContent(exec.response)
|
||||
if ts.channel == "pico" {
|
||||
go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID)
|
||||
} else {
|
||||
|
|
@ -496,7 +491,7 @@ func (p *Pipeline) CallLLM(
|
|||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: exec.response.Content,
|
||||
ReasoningContent: exec.response.ReasoningContent,
|
||||
ReasoningContent: reasoningContent,
|
||||
}
|
||||
for _, tc := range exec.normalizedToolCalls {
|
||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||
|
|
|
|||
|
|
@ -31,16 +31,8 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||
}
|
||||
ts.captureRestorePoint(history, summary)
|
||||
|
||||
messages := ts.agent.ContextBuilder.BuildMessages(
|
||||
history,
|
||||
summary,
|
||||
ts.userMessage,
|
||||
ts.media,
|
||||
ts.channel,
|
||||
ts.chatID,
|
||||
ts.opts.Dispatch.SenderID(),
|
||||
ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
||||
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
|
||||
)
|
||||
|
||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||
|
|
@ -69,22 +61,15 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||
history = resp.History
|
||||
summary = resp.Summary
|
||||
}
|
||||
messages = ts.agent.ContextBuilder.BuildMessages(
|
||||
history, summary, ts.userMessage,
|
||||
ts.media, ts.channel, ts.chatID,
|
||||
ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
||||
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
|
||||
)
|
||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||
}
|
||||
}
|
||||
|
||||
if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) {
|
||||
rootMsg := providers.Message{
|
||||
Role: "user",
|
||||
Content: ts.userMessage,
|
||||
Media: append([]string(nil), ts.media...),
|
||||
}
|
||||
rootMsg := userPromptMessage(ts.userMessage, ts.media)
|
||||
if len(rootMsg.Media) > 0 {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg)
|
||||
} else {
|
||||
|
|
|
|||
496
pkg/agent/prompt.go
Normal file
496
pkg/agent/prompt.go
Normal 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
|
||||
}
|
||||
}
|
||||
139
pkg/agent/prompt_contributors.go
Normal file
139
pkg/agent/prompt_contributors.go
Normal 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
275
pkg/agent/prompt_test.go
Normal 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
129
pkg/agent/prompt_turn.go
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers
|
|||
return fmt.Errorf("steering queue is not initialized")
|
||||
}
|
||||
|
||||
msg = steeringPromptMessage(msg)
|
||||
if err := al.steering.pushScope(scope, msg); err != nil {
|
||||
logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{
|
||||
"error": err.Error(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
|
|
@ -623,6 +624,10 @@ func (e *ephemeralSessionStore) AddMessage(_, role, content string) {
|
|||
}
|
||||
|
||||
func (e *ephemeralSessionStore) AddFullMessage(_ string, msg providers.Message) {
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history = append(e.history, msg)
|
||||
|
|
@ -652,6 +657,7 @@ func (e *ephemeralSessionStore) SetSummary(_, summary string) {
|
|||
func (e *ephemeralSessionStore) SetHistory(_ string, history []providers.Message) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
history = messageutil.FilterInvalidHistoryMessages(history)
|
||||
e.history = make([]providers.Message, len(history))
|
||||
copy(e.history, history)
|
||||
e.truncateLocked()
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
|||
case result, ok := <-ts.pendingResults:
|
||||
if ok && result != nil && result.ForLLM != "" {
|
||||
content := al.cfg.FilterSensitiveData(result.ForLLM)
|
||||
msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}
|
||||
msg := subTurnResultPromptMessage(content)
|
||||
pendingMessages = append(pendingMessages, msg)
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -527,10 +527,7 @@ func (ts *turnState) interruptHintMessage() providers.Message {
|
|||
if hint != "" {
|
||||
content += "\n\nInterrupt hint: " + hint
|
||||
}
|
||||
return providers.Message{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
}
|
||||
return interruptPromptMessage(content)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
prepared := strings.TrimSpace(content)
|
||||
if prepared == "" {
|
||||
|
|
@ -183,6 +197,13 @@ func prepareToolFeedbackMessageContent(ch Channel, content string) string {
|
|||
return prepared
|
||||
}
|
||||
|
||||
func (m *Manager) toolFeedbackSeparateMessagesEnabled() bool {
|
||||
if m == nil || m.config == nil {
|
||||
return false
|
||||
}
|
||||
return m.config.Agents.Defaults.IsToolFeedbackSeparateMessagesEnabled()
|
||||
}
|
||||
|
||||
// RecordPlaceholder registers a placeholder message for later editing.
|
||||
// Implements PlaceholderRecorder.
|
||||
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
|
||||
|
|
@ -264,6 +285,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
|||
}
|
||||
|
||||
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
||||
separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled()
|
||||
|
||||
// 3. If a stream already finalized this chat, stale tool feedback must be
|
||||
// dropped without consuming the final-response marker. Streaming finalization
|
||||
|
|
@ -288,14 +310,28 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
|
|||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
if separateToolFeedbackMessages {
|
||||
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
|
||||
}
|
||||
|
||||
// 5. Try editing placeholder
|
||||
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
||||
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
||||
if isToolFeedback && separateToolFeedbackMessages {
|
||||
if deleter, ok := ch.(MessageDeleter); ok {
|
||||
deleter.DeleteMessage(ctx, chatID, entry.id) // best effort
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if editor, ok := ch.(MessageEditor); ok {
|
||||
content := 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.
|
||||
m.streamActive.LoadAndDelete(key)
|
||||
|
||||
if m.toolFeedbackSeparateMessagesEnabled() {
|
||||
clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context)
|
||||
}
|
||||
|
||||
// 4. Delete placeholder if present.
|
||||
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
|
||||
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
|
||||
|
|
@ -408,15 +448,26 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (
|
|||
return &finalizeHookStreamer{
|
||||
Streamer: streamer,
|
||||
onFinalize: func(finalizeCtx context.Context) {
|
||||
dismissTrackedToolFeedbackMessage(
|
||||
finalizeCtx,
|
||||
ch,
|
||||
chatID,
|
||||
&bus.InboundContext{
|
||||
Channel: channelName,
|
||||
ChatID: chatID,
|
||||
},
|
||||
)
|
||||
if m.toolFeedbackSeparateMessagesEnabled() {
|
||||
clearTrackedToolFeedbackMessage(
|
||||
ch,
|
||||
chatID,
|
||||
&bus.InboundContext{
|
||||
Channel: channelName,
|
||||
ChatID: chatID,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
dismissTrackedToolFeedbackMessage(
|
||||
finalizeCtx,
|
||||
ch,
|
||||
chatID,
|
||||
&bus.InboundContext{
|
||||
Channel: channelName,
|
||||
ChatID: chatID,
|
||||
},
|
||||
)
|
||||
}
|
||||
m.streamActive.Store(key, true)
|
||||
},
|
||||
}, true
|
||||
|
|
|
|||
|
|
@ -804,6 +804,20 @@ type mockResolvedToolFeedbackEditor struct {
|
|||
resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string
|
||||
}
|
||||
|
||||
type mockDeletingMessageEditor struct {
|
||||
mockMessageEditor
|
||||
deleteCalls int
|
||||
deletedChatID string
|
||||
deletedMessageID string
|
||||
}
|
||||
|
||||
func (m *mockDeletingMessageEditor) DeleteMessage(_ context.Context, chatID, messageID string) error {
|
||||
m.deleteCalls++
|
||||
m.deletedChatID = chatID
|
||||
m.deletedMessageID = messageID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID(
|
||||
chatID string,
|
||||
outboundCtx *bus.InboundContext,
|
||||
|
|
@ -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) {
|
||||
m := newTestManager()
|
||||
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) {
|
||||
msg := testOutboundMessage(bus.OutboundMessage{
|
||||
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) {
|
||||
m := newTestManager()
|
||||
ch := &mockStreamingChannel{
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ package telegram
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var reRawURL = regexp.MustCompile(`https?://[^\s<]+`)
|
||||
|
||||
func markdownToTelegramHTML(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
|
|
@ -19,6 +23,9 @@ func markdownToTelegramHTML(text string) string {
|
|||
links := extractLinks(text)
|
||||
text = links.text
|
||||
|
||||
rawURLs := extractRawURLs(text)
|
||||
text = rawURLs.text
|
||||
|
||||
text = reHeading.ReplaceAllString(text, "$1")
|
||||
|
||||
text = reBlockquote.ReplaceAllString(text, "$1")
|
||||
|
|
@ -43,10 +50,19 @@ func markdownToTelegramHTML(text string) string {
|
|||
|
||||
for i, lnk := range links.links {
|
||||
label := escapeHTML(lnk[0])
|
||||
url := lnk[1]
|
||||
url := escapeHTMLAttr(lnk[1])
|
||||
text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`<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 {
|
||||
escaped := escapeHTML(code)
|
||||
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
|
||||
}
|
||||
|
||||
type rawURLMatch struct {
|
||||
text string
|
||||
urls []string
|
||||
}
|
||||
|
||||
func extractCodeBlocks(text string) codeBlockMatch {
|
||||
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
|
||||
|
||||
|
|
@ -110,6 +131,24 @@ func extractCodeBlocks(text string) codeBlockMatch {
|
|||
return codeBlockMatch{text: text, codes: codes}
|
||||
}
|
||||
|
||||
func extractRawURLs(text string) rawURLMatch {
|
||||
matches := reRawURL.FindAllString(text, -1)
|
||||
|
||||
urls := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
urls = append(urls, match)
|
||||
}
|
||||
|
||||
i := 0
|
||||
text = reRawURL.ReplaceAllStringFunc(text, func(string) string {
|
||||
placeholder := fmt.Sprintf("\x00RU%d\x00", i)
|
||||
i++
|
||||
return placeholder
|
||||
})
|
||||
|
||||
return rawURLMatch{text: text, urls: urls}
|
||||
}
|
||||
|
||||
type inlineCodeMatch struct {
|
||||
text string
|
||||
codes []string
|
||||
|
|
@ -139,3 +178,7 @@ func escapeHTML(text string) string {
|
|||
text = strings.ReplaceAll(text, ">", ">")
|
||||
return text
|
||||
}
|
||||
|
||||
func escapeHTMLAttr(text string) string {
|
||||
return html.EscapeString(text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
|
|||
input: "[click here](https://example.com/path)",
|
||||
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&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256">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</a>`,
|
||||
},
|
||||
{
|
||||
name: "link with underscores in URL is not corrupted by italic regex",
|
||||
// Google Flights URLs use URL-safe base64 with underscores in the tfs param.
|
||||
|
|
@ -45,6 +50,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
|
|||
input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
|
||||
expected: `<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&client_id=test-client">oauth</a>`,
|
||||
},
|
||||
{
|
||||
name: "link label with HTML special chars is escaped",
|
||||
input: "[a & b](https://example.com)",
|
||||
|
|
@ -55,6 +65,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
|
|||
input: "a & b < c > d",
|
||||
expected: "a & b < c > d",
|
||||
},
|
||||
{
|
||||
name: "code block with language",
|
||||
input: "```json\n{\n \"path\": \"README.md\"\n}\n```",
|
||||
expected: "<pre><code>{\n \"path\": \"README.md\"\n}\n</code></pre>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
|
|
|||
|
|
@ -247,8 +247,9 @@ type SubTurnConfig struct {
|
|||
}
|
||||
|
||||
type ToolFeedbackConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
|
||||
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"`
|
||||
MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"`
|
||||
SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"`
|
||||
}
|
||||
|
||||
type AgentDefaults struct {
|
||||
|
|
@ -299,6 +300,13 @@ func (d *AgentDefaults) IsToolFeedbackEnabled() bool {
|
|||
return d.ToolFeedback.Enabled
|
||||
}
|
||||
|
||||
// IsToolFeedbackSeparateMessagesEnabled returns true when each tool feedback
|
||||
// update should be sent as its own chat message instead of editing a single
|
||||
// in-place progress message.
|
||||
func (d *AgentDefaults) IsToolFeedbackSeparateMessagesEnabled() bool {
|
||||
return d.ToolFeedback.SeparateMessages
|
||||
}
|
||||
|
||||
// GetModelName returns the effective model name for the agent defaults.
|
||||
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
|
||||
func (d *AgentDefaults) GetModelName() string {
|
||||
|
|
|
|||
|
|
@ -787,6 +787,9 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) {
|
|||
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
||||
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false")
|
||||
}
|
||||
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
||||
t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
|
||||
|
|
@ -807,6 +810,9 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) {
|
|||
if cfg.Agents.Defaults.ToolFeedback.Enabled {
|
||||
t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file")
|
||||
}
|
||||
if cfg.Agents.Defaults.ToolFeedback.SeparateMessages {
|
||||
t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -35,8 +35,9 @@ func DefaultConfig() *Config {
|
|||
SummarizeTokenPercent: 75,
|
||||
SteeringMode: "one-at-a-time",
|
||||
ToolFeedback: ToolFeedbackConfig{
|
||||
Enabled: false,
|
||||
MaxArgsLength: 300,
|
||||
Enabled: false,
|
||||
MaxArgsLength: 300,
|
||||
SeparateMessages: false,
|
||||
},
|
||||
SplitOnMarker: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig,
|
|||
return fmt.Errorf("open process for job assignment: %w", err)
|
||||
}
|
||||
|
||||
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
|
||||
if err = windows.AssignProcessToJobObject(job, proc); err != nil {
|
||||
_ = windows.CloseHandle(proc)
|
||||
_ = windows.CloseHandle(job)
|
||||
if resources.token != 0 {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,24 @@ type headerTransport struct {
|
|||
headers map[string]string
|
||||
}
|
||||
|
||||
func expandHomeCommandPath(command string) string {
|
||||
if command == "" || command[0] != '~' {
|
||||
return command
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return command
|
||||
}
|
||||
if command == "~" {
|
||||
return home
|
||||
}
|
||||
if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") {
|
||||
return filepath.Join(home, command[2:])
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Clone the request to avoid modifying the original
|
||||
req = req.Clone(req.Context())
|
||||
|
|
@ -99,10 +117,12 @@ func loadEnvFile(path string) (map[string]string, error) {
|
|||
|
||||
// ServerConnection represents a connection to an MCP server
|
||||
type ServerConnection struct {
|
||||
Name string
|
||||
Client *mcp.Client
|
||||
Session *mcp.ClientSession
|
||||
Tools []*mcp.Tool
|
||||
Name string
|
||||
Config config.MCPServerConfig
|
||||
Client *mcp.Client
|
||||
Session *mcp.ClientSession
|
||||
Tools []*mcp.Tool
|
||||
reconnectMu sync.Mutex
|
||||
}
|
||||
|
||||
// Manager manages multiple MCP server connections
|
||||
|
|
@ -113,6 +133,8 @@ type Manager struct {
|
|||
wg sync.WaitGroup // tracks in-flight CallTool calls
|
||||
}
|
||||
|
||||
var connectServerFunc = connectServer
|
||||
|
||||
// NewManager creates a new MCP manager
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
|
|
@ -242,6 +264,28 @@ func (m *Manager) ConnectServer(
|
|||
name string,
|
||||
cfg config.MCPServerConfig,
|
||||
) error {
|
||||
conn, err := connectServerFunc(ctx, name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.closed.Load() {
|
||||
_ = conn.Session.Close()
|
||||
return fmt.Errorf("manager is closed")
|
||||
}
|
||||
|
||||
m.servers[name] = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectServer(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
cfg config.MCPServerConfig,
|
||||
) (*ServerConnection, error) {
|
||||
logger.InfoCF("mcp", "Connecting to MCP server",
|
||||
map[string]any{
|
||||
"server": name,
|
||||
|
|
@ -267,14 +311,14 @@ func (m *Manager) ConnectServer(
|
|||
} else if cfg.Command != "" {
|
||||
transportType = "stdio"
|
||||
} else {
|
||||
return fmt.Errorf("either URL or command must be provided")
|
||||
return nil, fmt.Errorf("either URL or command must be provided")
|
||||
}
|
||||
}
|
||||
|
||||
switch transportType {
|
||||
case "sse", "http":
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("URL is required for SSE/HTTP transport")
|
||||
return nil, fmt.Errorf("URL is required for SSE/HTTP transport")
|
||||
}
|
||||
|
||||
// Configure DisableStandaloneSSE based on transport type.
|
||||
|
|
@ -316,7 +360,7 @@ func (m *Manager) ConnectServer(
|
|||
transport = sseTransport
|
||||
case "stdio":
|
||||
if cfg.Command == "" {
|
||||
return fmt.Errorf("command is required for stdio transport")
|
||||
return nil, fmt.Errorf("command is required for stdio transport")
|
||||
}
|
||||
logger.DebugCF("mcp", "Using stdio transport",
|
||||
map[string]any{
|
||||
|
|
@ -324,7 +368,7 @@ func (m *Manager) ConnectServer(
|
|||
"command": cfg.Command,
|
||||
})
|
||||
// Create command with context
|
||||
cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...)
|
||||
cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...)
|
||||
|
||||
// Build environment variables with proper override semantics
|
||||
// Use a map to ensure config variables override file variables
|
||||
|
|
@ -341,7 +385,7 @@ func (m *Manager) ConnectServer(
|
|||
if cfg.EnvFile != "" {
|
||||
envVars, err := loadEnvFile(cfg.EnvFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err)
|
||||
return nil, fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err)
|
||||
}
|
||||
for k, v := range envVars {
|
||||
envMap[k] = v
|
||||
|
|
@ -367,7 +411,7 @@ func (m *Manager) ConnectServer(
|
|||
cmd.Env = env
|
||||
transport = &isolatedCommandTransport{Command: cmd}
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
return nil, fmt.Errorf(
|
||||
"unsupported transport type: %s (supported: stdio, sse, http)",
|
||||
transportType,
|
||||
)
|
||||
|
|
@ -376,7 +420,7 @@ func (m *Manager) ConnectServer(
|
|||
// Connect to server
|
||||
session, err := client.Connect(ctx, transport, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
||||
// Get server info
|
||||
|
|
@ -390,38 +434,19 @@ func (m *Manager) ConnectServer(
|
|||
})
|
||||
|
||||
// List available tools if supported
|
||||
var tools []*mcp.Tool
|
||||
if initResult.Capabilities.Tools != nil {
|
||||
for tool, err := range session.Tools(ctx, nil) {
|
||||
if err != nil {
|
||||
logger.WarnCF("mcp", "Error listing tool",
|
||||
map[string]any{
|
||||
"server": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
|
||||
logger.InfoCF("mcp", "Listed tools from MCP server",
|
||||
map[string]any{
|
||||
"server": name,
|
||||
"toolCount": len(tools),
|
||||
})
|
||||
tools, err := listServerTools(ctx, name, session, initResult)
|
||||
if err != nil {
|
||||
_ = session.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store connection
|
||||
m.mu.Lock()
|
||||
m.servers[name] = &ServerConnection{
|
||||
return &ServerConnection{
|
||||
Name: name,
|
||||
Config: cfg,
|
||||
Client: client,
|
||||
Session: session,
|
||||
Tools: tools,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetServers returns all connected servers
|
||||
|
|
@ -480,12 +505,131 @@ func (m *Manager) CallTool(
|
|||
|
||||
result, err := conn.Session.CallTool(ctx, params)
|
||||
if err != nil {
|
||||
if shouldReconnectCallError(err) {
|
||||
logger.WarnCF("mcp", "MCP server session was lost during tool call, reconnecting",
|
||||
map[string]any{
|
||||
"server": serverName,
|
||||
"tool": toolName,
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
||||
reconnectedConn, reconnectErr := m.reconnectServer(ctx, serverName, conn)
|
||||
if reconnectErr != nil {
|
||||
return nil, fmt.Errorf("failed to recover lost MCP session: %w", reconnectErr)
|
||||
}
|
||||
|
||||
result, err = reconnectedConn.Session.CallTool(ctx, params)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to call tool: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func listServerTools(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
session *mcp.ClientSession,
|
||||
initResult *mcp.InitializeResult,
|
||||
) ([]*mcp.Tool, error) {
|
||||
var tools []*mcp.Tool
|
||||
if initResult.Capabilities.Tools == nil {
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
for tool, err := range session.Tools(ctx, nil) {
|
||||
if err != nil {
|
||||
logger.WarnCF("mcp", "Error listing tool",
|
||||
map[string]any{
|
||||
"server": name,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
|
||||
logger.InfoCF("mcp", "Listed tools from MCP server",
|
||||
map[string]any{
|
||||
"server": name,
|
||||
"toolCount": len(tools),
|
||||
})
|
||||
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
func shouldReconnectCallError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, mcp.ErrSessionMissing) {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), mcp.ErrSessionMissing.Error())
|
||||
}
|
||||
|
||||
func (m *Manager) reconnectServer(
|
||||
ctx context.Context,
|
||||
serverName string,
|
||||
staleConn *ServerConnection,
|
||||
) (*ServerConnection, error) {
|
||||
if staleConn == nil {
|
||||
return nil, fmt.Errorf("server %s not found", serverName)
|
||||
}
|
||||
|
||||
staleConn.reconnectMu.Lock()
|
||||
defer staleConn.reconnectMu.Unlock()
|
||||
|
||||
if m.closed.Load() {
|
||||
return nil, fmt.Errorf("manager is closed")
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
currentConn, ok := m.servers[serverName]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("server %s not found", serverName)
|
||||
}
|
||||
if currentConn != staleConn {
|
||||
return currentConn, nil
|
||||
}
|
||||
|
||||
freshConn, err := connectServerFunc(ctx, serverName, staleConn.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.closed.Load() {
|
||||
m.mu.Unlock()
|
||||
_ = freshConn.Session.Close()
|
||||
return nil, fmt.Errorf("manager is closed")
|
||||
}
|
||||
|
||||
currentConn, ok = m.servers[serverName]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
_ = freshConn.Session.Close()
|
||||
return nil, fmt.Errorf("server %s not found", serverName)
|
||||
}
|
||||
|
||||
if currentConn == staleConn {
|
||||
m.servers[serverName] = freshConn
|
||||
staleToClose := staleConn
|
||||
m.mu.Unlock()
|
||||
_ = staleToClose.Session.Close()
|
||||
return freshConn, nil
|
||||
}
|
||||
|
||||
m.mu.Unlock()
|
||||
_ = freshConn.Session.Close()
|
||||
return currentConn, nil
|
||||
}
|
||||
|
||||
// Close closes all server connections
|
||||
func (m *Manager) Close() error {
|
||||
// Use Swap to atomically set closed=true and get the previous value
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
|
|
@ -136,6 +141,22 @@ func TestLoadEnvFileNotFound(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExpandHomeCommandPath(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
want := filepath.Join(homeDir, "bin", "my-mcp")
|
||||
got := expandHomeCommandPath("~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp"))
|
||||
if got != want {
|
||||
t.Fatalf("expandHomeCommandPath() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := expandHomeCommandPath("npx"); got != "npx" {
|
||||
t.Fatalf("expandHomeCommandPath() should leave bare commands unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvFilePriority(t *testing.T) {
|
||||
// Create a temporary .env file
|
||||
tmpDir := t.TempDir()
|
||||
|
|
@ -296,6 +317,81 @@ func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestCallTool_ReconnectsWhenHTTPServerLosesSession(t *testing.T) {
|
||||
originalConnectServerFunc := connectServerFunc
|
||||
t.Cleanup(func() {
|
||||
connectServerFunc = originalConnectServerFunc
|
||||
})
|
||||
|
||||
staleConn, staleTransport, err := newScriptedServerConnection(
|
||||
"session-1",
|
||||
nil,
|
||||
fmt.Errorf(`sending "tools/call": failed to connect (session ID: session-1): %w`, sdkmcp.ErrSessionMissing),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newScriptedServerConnection(stale) error = %v", err)
|
||||
}
|
||||
freshConn, freshTransport, err := newScriptedServerConnection(
|
||||
"session-2",
|
||||
&sdkmcp.CallToolResult{
|
||||
Content: []sdkmcp.Content{
|
||||
&sdkmcp.TextContent{Text: "reconnected"},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newScriptedServerConnection(fresh) error = %v", err)
|
||||
}
|
||||
|
||||
connectCalls := 0
|
||||
connectServerFunc = func(ctx context.Context, name string, cfg config.MCPServerConfig) (*ServerConnection, error) {
|
||||
connectCalls++
|
||||
if connectCalls == 1 {
|
||||
return freshConn, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected reconnect attempt %d", connectCalls)
|
||||
}
|
||||
|
||||
mgr := NewManager()
|
||||
mgr.servers["flaky"] = staleConn
|
||||
|
||||
result, err := mgr.CallTool(context.Background(), "flaky", "echo", map[string]any{
|
||||
"query": "hello",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool() error = %v", err)
|
||||
}
|
||||
if result == nil || len(result.Content) != 1 {
|
||||
t.Fatalf("CallTool() returned unexpected content: %#v", result)
|
||||
}
|
||||
|
||||
text, ok := result.Content[0].(*sdkmcp.TextContent)
|
||||
if !ok {
|
||||
t.Fatalf("CallTool() content type = %T, want *sdkmcp.TextContent", result.Content[0])
|
||||
}
|
||||
if text.Text != "reconnected" {
|
||||
t.Fatalf("CallTool() text = %q, want %q", text.Text, "reconnected")
|
||||
}
|
||||
|
||||
conn, ok := mgr.GetServer("flaky")
|
||||
if !ok {
|
||||
t.Fatal("expected flaky server to remain connected after reconnect")
|
||||
}
|
||||
if conn.Session.ID() != "session-2" {
|
||||
t.Fatalf("Session.ID() = %q, want %q", conn.Session.ID(), "session-2")
|
||||
}
|
||||
if connectCalls != 1 {
|
||||
t.Fatalf("connectCalls = %d, want 1", connectCalls)
|
||||
}
|
||||
if staleTransport.toolCallCalls != 1 {
|
||||
t.Fatalf("stale toolCallCalls = %d, want 1", staleTransport.toolCallCalls)
|
||||
}
|
||||
if freshTransport.toolCallCalls != 1 {
|
||||
t.Fatalf("fresh toolCallCalls = %d, want 1", freshTransport.toolCallCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClose_IdempotentOnEmptyManager(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
|
||||
|
|
@ -306,3 +402,138 @@ func TestClose_IdempotentOnEmptyManager(t *testing.T) {
|
|||
t.Fatalf("second close should be idempotent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newScriptedServerConnection(
|
||||
sessionID string,
|
||||
toolCallResult *sdkmcp.CallToolResult,
|
||||
toolCallErr error,
|
||||
) (*ServerConnection, *scriptedTransport, error) {
|
||||
transport := &scriptedTransport{
|
||||
sessionID: sessionID,
|
||||
toolCallResult: toolCallResult,
|
||||
toolCallErr: toolCallErr,
|
||||
}
|
||||
|
||||
client := sdkmcp.NewClient(&sdkmcp.Implementation{
|
||||
Name: "picoclaw-test",
|
||||
Version: "1.0.0",
|
||||
}, nil)
|
||||
session, err := client.Connect(context.Background(), transport, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return &ServerConnection{
|
||||
Name: "flaky",
|
||||
Config: config.MCPServerConfig{Enabled: true, Type: "http", URL: "https://example.invalid/mcp"},
|
||||
Client: client,
|
||||
Session: session,
|
||||
Tools: []*sdkmcp.Tool{
|
||||
{
|
||||
Name: "echo",
|
||||
Description: "Echo test tool",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
}, transport, nil
|
||||
}
|
||||
|
||||
type scriptedTransport struct {
|
||||
sessionID string
|
||||
toolCallResult *sdkmcp.CallToolResult
|
||||
toolCallErr error
|
||||
|
||||
mu sync.Mutex
|
||||
toolCallCalls int
|
||||
closed bool
|
||||
incoming chan jsonrpc.Message
|
||||
}
|
||||
|
||||
func (t *scriptedTransport) Connect(context.Context) (sdkmcp.Connection, error) {
|
||||
if t.incoming == nil {
|
||||
t.incoming = make(chan jsonrpc.Message, 4)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *scriptedTransport) Read(ctx context.Context) (jsonrpc.Message, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case msg, ok := <-t.incoming:
|
||||
if !ok {
|
||||
return nil, io.EOF
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *scriptedTransport) Write(ctx context.Context, msg jsonrpc.Message) error {
|
||||
req, ok := msg.(*jsonrpc.Request)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
payload, err := json.Marshal(&sdkmcp.InitializeResult{
|
||||
ProtocolVersion: "2025-11-25",
|
||||
ServerInfo: &sdkmcp.Implementation{
|
||||
Name: "scripted-test-server",
|
||||
Version: "1.0.0",
|
||||
},
|
||||
Capabilities: &sdkmcp.ServerCapabilities{
|
||||
Tools: &sdkmcp.ToolCapabilities{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}:
|
||||
return nil
|
||||
}
|
||||
|
||||
case "notifications/initialized":
|
||||
return nil
|
||||
|
||||
case "tools/call":
|
||||
t.mu.Lock()
|
||||
t.toolCallCalls++
|
||||
t.mu.Unlock()
|
||||
|
||||
if t.toolCallErr != nil {
|
||||
return t.toolCallErr
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(t.toolCallResult)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unexpected method %q", req.Method)
|
||||
}
|
||||
|
||||
func (t *scriptedTransport) Close() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.closed {
|
||||
return nil
|
||||
}
|
||||
t.closed = true
|
||||
close(t.incoming)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *scriptedTransport) SessionID() string {
|
||||
return t.sessionID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import (
|
|||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -405,12 +407,9 @@ func (s *JSONLStore) promoteAliasHistoryLocked(
|
|||
}
|
||||
|
||||
func (s *JSONLStore) sessionHasVisibleContentLocked(sessionKey string, meta SessionMeta) (bool, error) {
|
||||
if meta.Count-meta.Skip > 0 || strings.TrimSpace(meta.Summary) != "" {
|
||||
if strings.TrimSpace(meta.Summary) != "" {
|
||||
return true, nil
|
||||
}
|
||||
if meta.Count != 0 || meta.Skip != 0 {
|
||||
return false, nil
|
||||
}
|
||||
history, err := readMessages(s.jsonlPath(sessionKey), meta.Skip)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -482,6 +481,9 @@ func readMessages(path string, skip int) ([]providers.Message, error) {
|
|||
lineNum, filepath.Base(path), err)
|
||||
continue
|
||||
}
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
if scanner.Err() != nil {
|
||||
|
|
@ -494,28 +496,44 @@ func readMessages(path string, skip int) ([]providers.Message, error) {
|
|||
return msgs, nil
|
||||
}
|
||||
|
||||
// countLines counts the total number of non-empty lines in a .jsonl file.
|
||||
// Used by TruncateHistory to reconcile a stale meta.Count without
|
||||
// the overhead of unmarshaling every message.
|
||||
func countLines(path string) (int, error) {
|
||||
// scanRetainedMessageLines returns the total number of non-empty raw JSONL
|
||||
// lines plus the raw line numbers that survive readMessages filtering.
|
||||
// TruncateHistory uses this to compute keepLast against retained messages
|
||||
// while preserving the raw-line skip offset stored in metadata.
|
||||
func scanRetainedMessageLines(path string) (int, []int, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
return 0, []int{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("memory: open jsonl: %w", err)
|
||||
return 0, nil, fmt.Errorf("memory: open jsonl: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n := 0
|
||||
rawCount := 0
|
||||
retained := make([]int, 0)
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
|
||||
for scanner.Scan() {
|
||||
if len(scanner.Bytes()) > 0 {
|
||||
n++
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
rawCount++
|
||||
|
||||
var msg providers.Message
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
continue
|
||||
}
|
||||
retained = append(retained, rawCount)
|
||||
}
|
||||
return n, scanner.Err()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return rawCount, retained, nil
|
||||
}
|
||||
|
||||
func (s *JSONLStore) AddMessage(
|
||||
|
|
@ -535,6 +553,10 @@ func (s *JSONLStore) AddFullMessage(
|
|||
|
||||
// addMsg is the shared implementation for AddMessage and AddFullMessage.
|
||||
func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error {
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
return nil
|
||||
}
|
||||
|
||||
l := s.sessionLock(sessionKey)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
|
@ -655,24 +677,26 @@ func (s *JSONLStore) TruncateHistory(
|
|||
return err
|
||||
}
|
||||
|
||||
// Always reconcile meta.Count with the actual line count on disk.
|
||||
// A crash between the JSONL append and the meta update in addMsg
|
||||
// leaves meta.Count stale (e.g. file has 101 lines but meta says
|
||||
// 100). Counting lines is cheap — no unmarshal, just a scan — and
|
||||
// TruncateHistory is not a hot path, so always re-count.
|
||||
n, countErr := countLines(s.jsonlPath(sessionKey))
|
||||
if countErr != nil {
|
||||
return countErr
|
||||
rawCount, retainedRawLines, scanErr := scanRetainedMessageLines(s.jsonlPath(sessionKey))
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
meta.Count = n
|
||||
|
||||
if keepLast <= 0 {
|
||||
meta.Count = rawCount
|
||||
if meta.Skip > meta.Count {
|
||||
meta.Skip = meta.Count
|
||||
} else {
|
||||
effective := meta.Count - meta.Skip
|
||||
if keepLast < effective {
|
||||
meta.Skip = meta.Count - keepLast
|
||||
}
|
||||
}
|
||||
|
||||
activeStart := sort.Search(len(retainedRawLines), func(i int) bool {
|
||||
return retainedRawLines[i] > meta.Skip
|
||||
})
|
||||
activeRetainedCount := len(retainedRawLines) - activeStart
|
||||
|
||||
switch {
|
||||
case keepLast <= 0 || activeRetainedCount == 0:
|
||||
meta.Skip = meta.Count
|
||||
case keepLast < activeRetainedCount:
|
||||
activeRawLines := retainedRawLines[activeStart:]
|
||||
meta.Skip = activeRawLines[activeRetainedCount-keepLast-1]
|
||||
}
|
||||
meta.UpdatedAt = time.Now()
|
||||
|
||||
|
|
@ -684,6 +708,8 @@ func (s *JSONLStore) SetHistory(
|
|||
sessionKey string,
|
||||
history []providers.Message,
|
||||
) error {
|
||||
history = messageutil.FilterInvalidHistoryMessages(history)
|
||||
|
||||
l := s.sessionLock(sessionKey)
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
|
@ -762,6 +788,8 @@ func (s *JSONLStore) Compact(
|
|||
func (s *JSONLStore) rewriteJSONL(
|
||||
sessionKey string, msgs []providers.Message,
|
||||
) error {
|
||||
msgs = messageutil.FilterInvalidHistoryMessages(msgs)
|
||||
|
||||
var buf bytes.Buffer
|
||||
for i, msg := range msgs {
|
||||
line, err := json.Marshal(msg)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
|
@ -155,6 +157,27 @@ func TestAddFullMessage_ToolCallID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAddFullMessage_DropsTransientAssistantThought(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := store.AddFullMessage(ctx, "transient-thought", providers.Message{
|
||||
Role: "assistant",
|
||||
ReasoningContent: "internal chain of thought",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddFullMessage: %v", err)
|
||||
}
|
||||
|
||||
history, err := store.GetHistory(ctx, "transient-thought")
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history) != 0 {
|
||||
t.Fatalf("expected transient thought to be discarded, got %d messages", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHistory_EmptySession(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -243,6 +266,46 @@ func TestSetSummary_GetSummary(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetHistory_DropsTransientAssistantThought(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
newHistory := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "assistant", ReasoningContent: "internal chain of thought"},
|
||||
{Role: "assistant", Content: "visible answer", ReasoningContent: "visible thought"},
|
||||
}
|
||||
|
||||
err := store.SetHistory(ctx, "replace", newHistory)
|
||||
if err != nil {
|
||||
t.Fatalf("SetHistory: %v", err)
|
||||
}
|
||||
|
||||
history, err := store.GetHistory(ctx, "replace")
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("expected transient thought to be removed, got %d messages", len(history))
|
||||
}
|
||||
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||
t.Fatalf("history[0] = %+v, want user/hello", history[0])
|
||||
}
|
||||
if history[1].Role != "assistant" || history[1].Content != "visible answer" ||
|
||||
history[1].ReasoningContent != "visible thought" {
|
||||
t.Fatalf("history[1] = %+v, want assistant visible answer with reasoning", history[1])
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(store.jsonlPath("replace"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(jsonl): %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("jsonl line count = %d, want 2", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionMetaScopeAndAliasesPersist(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
|
@ -733,6 +796,56 @@ func TestTruncateHistory_StaleMetaCount(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTruncateHistory_IgnoresTransientThoughtForKeepLast(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
sessionKey := "transient-keep-last"
|
||||
now := time.Now()
|
||||
|
||||
rawJSONL := strings.Join([]string{
|
||||
`{"role":"user","content":"a"}`,
|
||||
`{"role":"assistant","content":"b"}`,
|
||||
`{"role":"assistant","content":"","reasoning_content":"dangling thought"}`,
|
||||
`{"role":"user","content":"c"}`,
|
||||
`{"role":"assistant","content":"d"}`,
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(store.jsonlPath(sessionKey), []byte(rawJSONL), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(jsonl): %v", err)
|
||||
}
|
||||
if err := store.writeMeta(sessionKey, SessionMeta{
|
||||
Key: sessionKey,
|
||||
Count: 5,
|
||||
Skip: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("writeMeta: %v", err)
|
||||
}
|
||||
|
||||
if err := store.TruncateHistory(ctx, sessionKey, 2); err != nil {
|
||||
t.Fatalf("TruncateHistory: %v", err)
|
||||
}
|
||||
|
||||
history, err := store.GetHistory(ctx, sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("expected 2 retained messages, got %d", len(history))
|
||||
}
|
||||
if history[0].Content != "c" || history[1].Content != "d" {
|
||||
t.Fatalf("kept history = %+v, want c,d", history)
|
||||
}
|
||||
|
||||
meta, err := store.readMeta(sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("readMeta: %v", err)
|
||||
}
|
||||
if meta.Skip != 2 {
|
||||
t.Fatalf("meta.Skip = %d, want 2 raw lines skipped", meta.Skip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrashRecovery_PartialLine(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -58,7 +58,12 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
|
|||
if data, err := readPidFileUnlocked(pidPath); err == nil {
|
||||
if os.Getpid() != data.PID {
|
||||
logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version)
|
||||
if isProcessRunning(data.PID) {
|
||||
// PID 1 is typically init/systemd on the host or the entrypoint
|
||||
// inside a container. When a container stops and leaves behind a
|
||||
// PID file on a shared volume, the host's PID 1 (init) would
|
||||
// pass the isProcessRunning check, blocking new gateway starts.
|
||||
// Treat recorded PID 1 as always stale.
|
||||
if data.PID != 1 && isProcessRunning(data.PID) {
|
||||
return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version)
|
||||
}
|
||||
logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath)
|
||||
|
|
@ -124,6 +129,14 @@ func ReadPidFileWithCheck(homePath string) *PidFileData {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Treat PID 1 as stale when we are not PID 1 ourselves (container
|
||||
// leftover on a shared volume — host PID 1 is init, not gateway).
|
||||
if data.PID == 1 && os.Getpid() != 1 {
|
||||
logger.Debugf("stale container PID 1, remove pid file: %s", pidPath)
|
||||
os.Remove(pidPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isProcessRunning(data.PID) {
|
||||
logger.Debugf("process not running, remove pid file: %s", pidPath)
|
||||
os.Remove(pidPath)
|
||||
|
|
|
|||
|
|
@ -278,6 +278,46 @@ func TestRemovePidFileIfPIDMismatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestWritePidFileContainerPID1 verifies that a leftover PID file with PID 1
|
||||
// (typical container entrypoint) is treated as stale and overwritten.
|
||||
func TestWritePidFileContainerPID1(t *testing.T) {
|
||||
dir := tmpDir(t)
|
||||
|
||||
stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"}
|
||||
raw, _ := json.MarshalIndent(stale, "", " ")
|
||||
os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600)
|
||||
|
||||
data, err := WritePidFile(dir, "127.0.0.1", 18790)
|
||||
if err != nil {
|
||||
t.Fatalf("WritePidFile should treat PID 1 as stale, got error: %v", err)
|
||||
}
|
||||
if data.PID != os.Getpid() {
|
||||
t.Errorf("PID = %d, want %d", data.PID, os.Getpid())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadPidFileWithCheckContainerPID1 verifies that a leftover PID file
|
||||
// with PID 1 is treated as stale and cleaned up.
|
||||
func TestReadPidFileWithCheckContainerPID1(t *testing.T) {
|
||||
if os.Getpid() == 1 {
|
||||
t.Skip("test not meaningful when running as PID 1")
|
||||
}
|
||||
dir := tmpDir(t)
|
||||
|
||||
stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"}
|
||||
raw, _ := json.MarshalIndent(stale, "", " ")
|
||||
os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600)
|
||||
|
||||
data := ReadPidFileWithCheck(dir)
|
||||
if data != nil {
|
||||
t.Error("expected nil for PID 1 leftover")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) {
|
||||
t.Error("PID 1 leftover file should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadPidFileUnlockedInvalidJSON returns error for malformed content.
|
||||
func TestReadPidFileUnlockedInvalidJSON(t *testing.T) {
|
||||
dir := tmpDir(t)
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
if apiBase == "" {
|
||||
apiBase = getDefaultAPIBase(protocol)
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
|
|
@ -187,7 +187,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.RequestTimeout,
|
||||
cfg.ExtraBody,
|
||||
cfg.CustomHeaders,
|
||||
), modelID, nil
|
||||
)
|
||||
provider.SetProviderName(protocol)
|
||||
return provider, modelID, nil
|
||||
|
||||
case "azure", "azure-openai":
|
||||
// Azure OpenAI uses deployment-based URLs, api-key header auth,
|
||||
|
|
@ -257,7 +259,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
if apiBase == "" {
|
||||
apiBase = getDefaultAPIBase(protocol)
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
|
|
@ -266,7 +268,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.RequestTimeout,
|
||||
cfg.ExtraBody,
|
||||
cfg.CustomHeaders,
|
||||
), modelID, nil
|
||||
)
|
||||
provider.SetProviderName(protocol)
|
||||
return provider, modelID, nil
|
||||
|
||||
case "gemini":
|
||||
if cfg.APIKey() == "" && cfg.APIBase == "" {
|
||||
|
|
@ -302,7 +306,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
if _, ok := extraBody["reasoning_split"]; !ok {
|
||||
extraBody["reasoning_split"] = true
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
|
|
@ -311,7 +315,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.RequestTimeout,
|
||||
extraBody,
|
||||
cfg.CustomHeaders,
|
||||
), modelID, nil
|
||||
)
|
||||
provider.SetProviderName(protocol)
|
||||
return provider, modelID, nil
|
||||
|
||||
case "anthropic":
|
||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
||||
|
|
@ -330,7 +336,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
if cfg.APIKey() == "" {
|
||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||
}
|
||||
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
provider := NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||
cfg.APIKey(),
|
||||
apiBase,
|
||||
cfg.Proxy,
|
||||
|
|
@ -339,7 +345,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
cfg.RequestTimeout,
|
||||
cfg.ExtraBody,
|
||||
cfg.CustomHeaders,
|
||||
), modelID, nil
|
||||
)
|
||||
provider.SetProviderName(protocol)
|
||||
return provider, modelID, nil
|
||||
|
||||
case "anthropic-messages":
|
||||
// Anthropic Messages API with native format (HTTP-based, no SDK)
|
||||
|
|
|
|||
|
|
@ -77,3 +77,10 @@ func (p *HTTPProvider) GetDefaultModel() string {
|
|||
func (p *HTTPProvider) SupportsNativeSearch() bool {
|
||||
return p.delegate.SupportsNativeSearch()
|
||||
}
|
||||
|
||||
func (p *HTTPProvider) SetProviderName(providerName string) {
|
||||
if p == nil || p.delegate == nil {
|
||||
return
|
||||
}
|
||||
p.delegate.SetProviderName(providerName)
|
||||
}
|
||||
|
|
|
|||
38
pkg/providers/messageutil/messageutil.go
Normal file
38
pkg/providers/messageutil/messageutil.go
Normal 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
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers/common"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||
)
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ type (
|
|||
type Provider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
providerName string
|
||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||
httpClient *http.Client
|
||||
extraBody map[string]any // Additional fields to inject into request body
|
||||
|
|
@ -95,6 +97,12 @@ func WithCustomHeaders(customHeaders map[string]string) Option {
|
|||
}
|
||||
}
|
||||
|
||||
func WithProviderName(providerName string) Option {
|
||||
return func(p *Provider) {
|
||||
p.providerName = strings.ToLower(strings.TrimSpace(providerName))
|
||||
}
|
||||
}
|
||||
|
||||
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||
p := &Provider{
|
||||
apiKey: apiKey,
|
||||
|
|
@ -136,7 +144,7 @@ func (p *Provider) buildRequestBody(
|
|||
|
||||
requestBody := map[string]any{
|
||||
"model": model,
|
||||
"messages": common.SerializeMessages(messages),
|
||||
"messages": common.SerializeMessages(p.prepareMessagesForRequest(messages)),
|
||||
}
|
||||
|
||||
// When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview.
|
||||
|
|
@ -196,6 +204,111 @@ func (p *Provider) applyCustomHeaders(req *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (p *Provider) SetProviderName(providerName string) {
|
||||
p.providerName = strings.ToLower(strings.TrimSpace(providerName))
|
||||
}
|
||||
|
||||
func (p *Provider) prepareMessagesForRequest(messages []Message) []Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.isDeepSeekReasoningProvider() {
|
||||
return filterDeepSeekReasoningMessages(messages)
|
||||
}
|
||||
return stripReasoningMessages(messages)
|
||||
}
|
||||
|
||||
func (p *Provider) isDeepSeekReasoningProvider() bool {
|
||||
return p.providerName == "deepseek" || isDeepSeekHost(p.apiBase)
|
||||
}
|
||||
|
||||
func isDeepSeekHost(apiBase string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(apiBase))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
return host == "deepseek.com" || strings.HasSuffix(host, ".deepseek.com")
|
||||
}
|
||||
|
||||
func filterDeepSeekReasoningMessages(messages []Message) []Message {
|
||||
out := make([]Message, 0, len(messages))
|
||||
start := 0
|
||||
|
||||
flush := func(end int) {
|
||||
if end <= start {
|
||||
return
|
||||
}
|
||||
out = append(out, filterDeepSeekReasoningTurn(messages[start:end])...)
|
||||
start = end
|
||||
}
|
||||
|
||||
for i := 1; i < len(messages); i++ {
|
||||
if messages[i].Role == "user" {
|
||||
flush(i)
|
||||
}
|
||||
}
|
||||
flush(len(messages))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func filterDeepSeekReasoningTurn(messages []Message) []Message {
|
||||
hasToolInteraction := false
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) {
|
||||
hasToolInteraction = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Message, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
cloned := msg
|
||||
if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction {
|
||||
cloned.ReasoningContent = ""
|
||||
}
|
||||
if assistantMessageEmpty(cloned) {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloned)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func stripReasoningMessages(messages []Message) []Message {
|
||||
out := make([]Message, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
cloned := msg
|
||||
cloned.ReasoningContent = ""
|
||||
if assistantMessageEmpty(cloned) {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloned)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assistantMessageEmpty(msg Message) bool {
|
||||
return msg.Role == "assistant" &&
|
||||
strings.TrimSpace(msg.Content) == "" &&
|
||||
strings.TrimSpace(msg.ReasoningContent) == "" &&
|
||||
len(msg.ToolCalls) == 0 &&
|
||||
len(msg.Media) == 0 &&
|
||||
len(msg.Attachments) == 0 &&
|
||||
strings.TrimSpace(msg.ToolCallID) == ""
|
||||
}
|
||||
|
||||
func (p *Provider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
|
||||
func TestProviderChat_StripsReasoningContentForNonDeepSeekHistory(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -225,8 +225,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
|
|||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
|
||||
// Simulate a multi-turn conversation where the assistant's previous
|
||||
// reply included reasoning_content (e.g. from kimi-k2.5).
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What is 1+1?"},
|
||||
{Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"},
|
||||
|
|
@ -238,7 +236,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
|
|||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify reasoning_content is preserved in the serialized request.
|
||||
reqMessages, ok := requestBody["messages"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||
|
|
@ -247,11 +244,288 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1])
|
||||
}
|
||||
if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" {
|
||||
t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"])
|
||||
if _, exists := assistantMsg["reasoning_content"]; exists {
|
||||
t.Fatalf(
|
||||
"reasoning_content should be stripped for non-DeepSeek providers, got %v",
|
||||
assistantMsg["reasoning_content"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_DeepSeekOmitsReasoningContentForNonToolTurnHistory(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{"content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
p.apiBase = "https://api.deepseek.com/v1"
|
||||
p.httpClient = &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
r.URL, _ = url.Parse(server.URL + r.URL.Path)
|
||||
return http.DefaultTransport.RoundTrip(r)
|
||||
}),
|
||||
}
|
||||
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What is 1+1?"},
|
||||
{Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"},
|
||||
{Role: "user", Content: "What about 2+2?"},
|
||||
}
|
||||
|
||||
_, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
reqMessages, ok := requestBody["messages"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||
}
|
||||
assistantMsg, ok := reqMessages[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1])
|
||||
}
|
||||
if _, exists := assistantMsg["reasoning_content"]; exists {
|
||||
t.Fatalf(
|
||||
"reasoning_content should be omitted for DeepSeek non-tool turns, got %v",
|
||||
assistantMsg["reasoning_content"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_DeepSeekPreservesReasoningContentForToolTurnHistory(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{"content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
p.SetProviderName("deepseek")
|
||||
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "How's the weather tomorrow?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Let me check the date first.",
|
||||
ReasoningContent: "I need tomorrow's date before checking the weather.",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "get_date",
|
||||
Arguments: "{}",
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_1", Content: "2026-04-24"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Tomorrow is 2026-04-25.",
|
||||
ReasoningContent: "Now I can share the final answer.",
|
||||
},
|
||||
{Role: "user", Content: "What about Guangzhou?"},
|
||||
}
|
||||
|
||||
_, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
reqMessages, ok := requestBody["messages"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||
}
|
||||
if len(reqMessages) != len(messages) {
|
||||
t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages))
|
||||
}
|
||||
|
||||
firstAssistant, ok := reqMessages[1].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("first assistant message is not map[string]any: %T", reqMessages[1])
|
||||
}
|
||||
if firstAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." {
|
||||
t.Fatalf("first assistant reasoning_content = %v, want preserved", firstAssistant["reasoning_content"])
|
||||
}
|
||||
|
||||
finalAssistant, ok := reqMessages[3].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[3])
|
||||
}
|
||||
if finalAssistant["reasoning_content"] != "Now I can share the final answer." {
|
||||
t.Fatalf("final assistant reasoning_content = %v, want preserved", finalAssistant["reasoning_content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderChat_HistoryCanonicalizationMatrix(t *testing.T) {
|
||||
baseMessages := []Message{
|
||||
{Role: "user", Content: "turn1"},
|
||||
{Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"},
|
||||
{Role: "user", Content: "turn2"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ReasoningContent: "tool thought",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_read_file",
|
||||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_read_file", Content: "file content"},
|
||||
{Role: "user", Content: "turn3"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "tool visible only",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_list_dir",
|
||||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "list_dir",
|
||||
Arguments: `{"path":"."}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_list_dir", Content: "dir listing"},
|
||||
{Role: "user", Content: "turn4"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "tool visible and thought",
|
||||
ReasoningContent: "tool mixed thought",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "call_exec",
|
||||
Type: "function",
|
||||
Function: &FunctionCall{
|
||||
Name: "exec",
|
||||
Arguments: `{"command":"pwd"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolCallID: "call_exec", Content: "pwd output"},
|
||||
{Role: "user", Content: "current turn"},
|
||||
}
|
||||
|
||||
captureRequestMessages := func(t *testing.T, providerName string) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var requestBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"message": map[string]any{"content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := NewProvider("key", server.URL, "")
|
||||
if providerName != "" {
|
||||
p.SetProviderName(providerName)
|
||||
}
|
||||
|
||||
_, err := p.Chat(t.Context(), baseMessages, nil, "test-model", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
rawMessages, ok := requestBody["messages"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages is not []any: %T", requestBody["messages"])
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(rawMessages))
|
||||
for i, raw := range rawMessages {
|
||||
msg, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages[%d] is %T, want map[string]any", i, raw)
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("deepseek", func(t *testing.T) {
|
||||
msgs := captureRequestMessages(t, "deepseek")
|
||||
if len(msgs) != len(baseMessages) {
|
||||
t.Fatalf("len(messages) = %d, want %d", len(msgs), len(baseMessages))
|
||||
}
|
||||
|
||||
if _, ok := msgs[1]["reasoning_content"]; ok {
|
||||
t.Fatalf(
|
||||
"turn1 reasoning_content should be stripped for DeepSeek non-tool turn, got %v",
|
||||
msgs[1]["reasoning_content"],
|
||||
)
|
||||
}
|
||||
if msgs[3]["reasoning_content"] != "tool thought" {
|
||||
t.Fatalf("turn2 reasoning_content = %v, want preserved", msgs[3]["reasoning_content"])
|
||||
}
|
||||
if _, ok := msgs[6]["reasoning_content"]; ok {
|
||||
t.Fatalf("turn3 reasoning_content should be absent, got %v", msgs[6]["reasoning_content"])
|
||||
}
|
||||
if msgs[9]["reasoning_content"] != "tool mixed thought" {
|
||||
t.Fatalf("turn4 reasoning_content = %v, want preserved", msgs[9]["reasoning_content"])
|
||||
}
|
||||
if msgs[9]["content"] != "tool visible and thought" {
|
||||
t.Fatalf("turn4 content = %v, want preserved", msgs[9]["content"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-deepseek", func(t *testing.T) {
|
||||
msgs := captureRequestMessages(t, "")
|
||||
for i, msg := range msgs {
|
||||
if _, ok := msg["reasoning_content"]; ok {
|
||||
t.Fatalf(
|
||||
"messages[%d] reasoning_content should be stripped for non-DeepSeek providers, got %v",
|
||||
i,
|
||||
msg["reasoning_content"],
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProviderChat_HTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ type ContentBlock struct {
|
|||
Type string `json:"type"` // "text"
|
||||
Text string `json:"text"`
|
||||
CacheControl *CacheControl `json:"cache_control,omitempty"`
|
||||
|
||||
// Prompt metadata is internal to the agent runtime. It records which
|
||||
// structured prompt segment produced this block without changing provider
|
||||
// JSON.
|
||||
PromptLayer string `json:"-"`
|
||||
PromptSlot string `json:"-"`
|
||||
PromptSource string `json:"-"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
|
|
@ -80,11 +87,24 @@ type Message struct {
|
|||
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
|
||||
// Prompt metadata is internal to the agent runtime. It records where a
|
||||
// message or system part came from without changing provider/session JSON.
|
||||
PromptLayer string `json:"-"`
|
||||
PromptSlot string `json:"-"`
|
||||
PromptSource string `json:"-"`
|
||||
}
|
||||
|
||||
type ToolDefinition struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunctionDefinition `json:"function"`
|
||||
|
||||
// Prompt metadata is internal to the agent runtime. Tool definitions are
|
||||
// model-visible capability prompts even though providers send them outside
|
||||
// the system message.
|
||||
PromptLayer string `json:"-"`
|
||||
PromptSlot string `json:"-"`
|
||||
PromptSource string `json:"-"`
|
||||
}
|
||||
|
||||
type ToolFunctionDefinition struct {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
|
|
@ -69,6 +70,10 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
|
|||
// AddFullMessage adds a complete message with tool calls and tool call ID to the session.
|
||||
// This is used to save the full conversation flow including tool calls and tool results.
|
||||
func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
return
|
||||
}
|
||||
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
|
|
@ -196,8 +201,7 @@ func (sm *SessionManager) Save(key string) error {
|
|||
Updated: stored.Updated,
|
||||
}
|
||||
if len(stored.Messages) > 0 {
|
||||
snapshot.Messages = make([]providers.Message, len(stored.Messages))
|
||||
copy(snapshot.Messages, stored.Messages)
|
||||
snapshot.Messages = messageutil.FilterInvalidHistoryMessages(stored.Messages)
|
||||
} else {
|
||||
snapshot.Messages = []providers.Message{}
|
||||
}
|
||||
|
|
@ -270,6 +274,7 @@ func (sm *SessionManager) loadSessions() error {
|
|||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
continue
|
||||
}
|
||||
session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages)
|
||||
|
||||
sm.sessions[session.Key] = &session
|
||||
}
|
||||
|
|
@ -290,6 +295,7 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
|
|||
|
||||
session, ok := sm.sessions[key]
|
||||
if ok {
|
||||
history = messageutil.FilterInvalidHistoryMessages(history)
|
||||
// Create a deep copy to strictly isolate internal state
|
||||
// from the caller's slice.
|
||||
msgs := make([]providers.Message, len(history))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
|
||||
)
|
||||
|
||||
// MCPManager defines the interface for MCP manager operations
|
||||
|
|
@ -161,6 +162,14 @@ func (t *MCPTool) Description() string {
|
|||
return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc)
|
||||
}
|
||||
|
||||
func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata {
|
||||
return toolshared.PromptMetadata{
|
||||
Layer: toolshared.ToolPromptLayerCapability,
|
||||
Slot: toolshared.ToolPromptSlotMCP,
|
||||
Source: "mcp:" + sanitizeIdentifierComponent(t.serverName),
|
||||
}
|
||||
}
|
||||
|
||||
// Parameters returns the tool parameters schema
|
||||
func (t *MCPTool) Parameters() map[string]any {
|
||||
// The InputSchema is already a JSON Schema object
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
|
||||
)
|
||||
|
||||
// MockMCPManager is a mock implementation of MCPManager interface for testing
|
||||
|
|
@ -104,6 +105,22 @@ func TestMCPTool_Name(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMCPTool_PromptMetadata(t *testing.T) {
|
||||
manager := &MockMCPManager{}
|
||||
tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"})
|
||||
|
||||
metadata := tool.PromptMetadata()
|
||||
if metadata.Layer != toolshared.ToolPromptLayerCapability {
|
||||
t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability)
|
||||
}
|
||||
if metadata.Slot != toolshared.ToolPromptSlotMCP {
|
||||
t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP)
|
||||
}
|
||||
if metadata.Source != "mcp:github_server" {
|
||||
t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPTool_Description verifies tool description generation
|
||||
func TestMCPTool_Description(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ var (
|
|||
reSogouRealURL = regexp.MustCompile(`url=([^&]+)`)
|
||||
)
|
||||
|
||||
var preferredWebSearchLanguage atomic.Value
|
||||
|
||||
type APIKeyPool struct {
|
||||
keys []string
|
||||
current uint32
|
||||
|
|
@ -250,27 +248,6 @@ func mapBaiduRecencyFilter(rangeCode string) string {
|
|||
}
|
||||
}
|
||||
|
||||
func normalizePreferredWebSearchLanguage(lang string) string {
|
||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||
switch {
|
||||
case strings.HasPrefix(lang, "zh"), lang == "chinese":
|
||||
return "zh"
|
||||
case strings.HasPrefix(lang, "en"), lang == "english":
|
||||
return "en"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func SetPreferredWebSearchLanguage(lang string) {
|
||||
preferredWebSearchLanguage.Store(normalizePreferredWebSearchLanguage(lang))
|
||||
}
|
||||
|
||||
func GetPreferredWebSearchLanguage() string {
|
||||
lang, _ := preferredWebSearchLanguage.Load().(string)
|
||||
return lang
|
||||
}
|
||||
|
||||
type BraveSearchProvider struct {
|
||||
keyPool *APIKeyPool
|
||||
proxy string
|
||||
|
|
@ -1420,7 +1397,7 @@ func containsLatinLetter(text string) bool {
|
|||
func prefersDuckDuckGoQuery(text string) bool {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return GetPreferredWebSearchLanguage() == "en"
|
||||
return false
|
||||
}
|
||||
if containsHan(trimmed) {
|
||||
return false
|
||||
|
|
@ -1428,7 +1405,7 @@ func prefersDuckDuckGoQuery(text string) bool {
|
|||
if containsLatinLetter(trimmed) {
|
||||
return true
|
||||
}
|
||||
return GetPreferredWebSearchLanguage() == "en"
|
||||
return false
|
||||
}
|
||||
|
||||
func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) {
|
||||
|
|
|
|||
|
|
@ -1778,11 +1778,6 @@ func TestApplySogouRangeHint(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestPrefersDuckDuckGoQuery(t *testing.T) {
|
||||
SetPreferredWebSearchLanguage("")
|
||||
t.Cleanup(func() {
|
||||
SetPreferredWebSearchLanguage("")
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
|
|
@ -1805,19 +1800,9 @@ func TestPrefersDuckDuckGoQuery(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPrefersDuckDuckGoQuery_FallsBackToPreferredLanguage(t *testing.T) {
|
||||
SetPreferredWebSearchLanguage("en")
|
||||
t.Cleanup(func() {
|
||||
SetPreferredWebSearchLanguage("")
|
||||
})
|
||||
|
||||
if !prefersDuckDuckGoQuery("2026 04 15") {
|
||||
t.Fatal("numeric query should prefer DuckDuckGo when preferred language is English")
|
||||
}
|
||||
|
||||
SetPreferredWebSearchLanguage("zh")
|
||||
func TestPrefersDuckDuckGoQuery_DoesNotUseGlobalLanguageFallback(t *testing.T) {
|
||||
if prefersDuckDuckGoQuery("2026 04 15") {
|
||||
t.Fatal("numeric query should prefer Sogou when preferred language is Chinese")
|
||||
t.Fatal("numeric query should default to Sogou when no script-specific hint is present")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,14 +65,6 @@ func NewAPIKeyPool(keys []string) *APIKeyPool {
|
|||
return integrationtools.NewAPIKeyPool(keys)
|
||||
}
|
||||
|
||||
func SetPreferredWebSearchLanguage(lang string) {
|
||||
integrationtools.SetPreferredWebSearchLanguage(lang)
|
||||
}
|
||||
|
||||
func GetPreferredWebSearchLanguage() string {
|
||||
return integrationtools.GetPreferredWebSearchLanguage()
|
||||
}
|
||||
|
||||
func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
|
||||
return integrationtools.WebSearchToolOptionsFromConfig(cfg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
|||
name, _ := fn["name"].(string)
|
||||
desc, _ := fn["description"].(string)
|
||||
params, _ := fn["parameters"].(map[string]any)
|
||||
metadata := promptMetadataForTool(entry.Tool)
|
||||
|
||||
definitions = append(definitions, providers.ToolDefinition{
|
||||
Type: "function",
|
||||
|
|
@ -360,11 +361,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
|||
Description: desc,
|
||||
Parameters: params,
|
||||
},
|
||||
PromptLayer: metadata.Layer,
|
||||
PromptSlot: metadata.Slot,
|
||||
PromptSource: metadata.Source,
|
||||
})
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
func promptMetadataForTool(tool Tool) PromptMetadata {
|
||||
metadata := PromptMetadata{
|
||||
Layer: ToolPromptLayerCapability,
|
||||
Slot: ToolPromptSlotTooling,
|
||||
Source: ToolPromptSourceRegistry,
|
||||
}
|
||||
if provider, ok := tool.(PromptMetadataProvider); ok {
|
||||
provided := provider.PromptMetadata()
|
||||
if provided.Layer != "" {
|
||||
metadata.Layer = provided.Layer
|
||||
}
|
||||
if provided.Slot != "" {
|
||||
metadata.Slot = provided.Slot
|
||||
}
|
||||
if provided.Source != "" {
|
||||
metadata.Source = provided.Source
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// List returns a list of all registered tool names.
|
||||
func (r *ToolRegistry) List() []string {
|
||||
r.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T
|
|||
return m.result
|
||||
}
|
||||
|
||||
type mockPromptMetadataTool struct {
|
||||
mockRegistryTool
|
||||
metadata PromptMetadata
|
||||
}
|
||||
|
||||
func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata {
|
||||
return m.metadata
|
||||
}
|
||||
|
||||
type mockAsyncRegistryTool struct {
|
||||
mockRegistryTool
|
||||
lastCB AsyncCallback
|
||||
|
|
@ -375,6 +384,47 @@ func TestToolToSchema(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
r.Register(newMockTool("native", "native tool"))
|
||||
r.Register(&mockPromptMetadataTool{
|
||||
mockRegistryTool: mockRegistryTool{
|
||||
name: "mcp_demo",
|
||||
desc: "mcp tool",
|
||||
params: map[string]any{"type": "object"},
|
||||
},
|
||||
metadata: PromptMetadata{
|
||||
Layer: ToolPromptLayerCapability,
|
||||
Slot: ToolPromptSlotMCP,
|
||||
Source: "mcp:demo",
|
||||
},
|
||||
})
|
||||
|
||||
defs := r.ToProviderDefs()
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs))
|
||||
}
|
||||
|
||||
byName := make(map[string]providers.ToolDefinition, len(defs))
|
||||
for _, def := range defs {
|
||||
byName[def.Function.Name] = def
|
||||
}
|
||||
|
||||
native := byName["native"]
|
||||
if native.PromptLayer != ToolPromptLayerCapability ||
|
||||
native.PromptSlot != ToolPromptSlotTooling ||
|
||||
native.PromptSource != ToolPromptSourceRegistry {
|
||||
t.Fatalf("native prompt metadata = %#v, want default tooling source", native)
|
||||
}
|
||||
|
||||
mcp := byName["mcp_demo"]
|
||||
if mcp.PromptLayer != ToolPromptLayerCapability ||
|
||||
mcp.PromptSlot != ToolPromptSlotMCP ||
|
||||
mcp.PromptSource != "mcp:demo" {
|
||||
t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolRegistry_Clone(t *testing.T) {
|
||||
r := NewToolRegistry()
|
||||
r.Register(newMockTool("read_file", "reads files"))
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string {
|
|||
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
|
||||
}
|
||||
|
||||
func (t *RegexSearchTool) PromptMetadata() PromptMetadata {
|
||||
return PromptMetadata{
|
||||
Layer: ToolPromptLayerCapability,
|
||||
Slot: ToolPromptSlotTooling,
|
||||
Source: ToolPromptSourceDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RegexSearchTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
|
@ -95,6 +103,14 @@ func (t *BM25SearchTool) Description() string {
|
|||
return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools."
|
||||
}
|
||||
|
||||
func (t *BM25SearchTool) PromptMetadata() PromptMetadata {
|
||||
return PromptMetadata{
|
||||
Layer: ToolPromptLayerCapability,
|
||||
Slot: ToolPromptSlotTooling,
|
||||
Source: ToolPromptSourceDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BM25SearchTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,24 @@ type Tool interface {
|
|||
Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||
}
|
||||
|
||||
const (
|
||||
ToolPromptLayerCapability = "capability"
|
||||
ToolPromptSlotTooling = "tooling"
|
||||
ToolPromptSlotMCP = "mcp"
|
||||
ToolPromptSourceRegistry = "tool_registry:native"
|
||||
ToolPromptSourceDiscovery = "tool_registry:discovery"
|
||||
)
|
||||
|
||||
type PromptMetadata struct {
|
||||
Layer string
|
||||
Slot string
|
||||
Source string
|
||||
}
|
||||
|
||||
type PromptMetadataProvider interface {
|
||||
PromptMetadata() PromptMetadata
|
||||
}
|
||||
|
||||
// --- Request-scoped tool context (channel / chatID) ---
|
||||
//
|
||||
// Carried via context.Value so that concurrent tool calls each receive
|
||||
|
|
|
|||
|
|
@ -22,12 +22,20 @@ type (
|
|||
Tool = toolshared.Tool
|
||||
AsyncCallback = toolshared.AsyncCallback
|
||||
AsyncExecutor = toolshared.AsyncExecutor
|
||||
PromptMetadata = toolshared.PromptMetadata
|
||||
PromptMetadataProvider = toolshared.PromptMetadataProvider
|
||||
ToolResult = toolshared.ToolResult
|
||||
)
|
||||
|
||||
const (
|
||||
handledToolLLMNote = toolshared.HandledToolLLMNote
|
||||
artifactPathsLLMNote = toolshared.ArtifactPathsLLMNote
|
||||
|
||||
ToolPromptLayerCapability = toolshared.ToolPromptLayerCapability
|
||||
ToolPromptSlotTooling = toolshared.ToolPromptSlotTooling
|
||||
ToolPromptSlotMCP = toolshared.ToolPromptSlotMCP
|
||||
ToolPromptSourceRegistry = toolshared.ToolPromptSourceRegistry
|
||||
ToolPromptSourceDiscovery = toolshared.ToolPromptSourceDiscovery
|
||||
)
|
||||
|
||||
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
|
||||
|
|
|
|||
|
|
@ -7,21 +7,31 @@ import (
|
|||
|
||||
const ToolFeedbackContinuationHint = "Continuing the current task."
|
||||
|
||||
// FormatToolFeedbackMessage renders the model-provided explanation for why a
|
||||
// tool is being executed. When the model does not provide one, it keeps only
|
||||
// the tool line and does not expose raw arguments or fallback text.
|
||||
func FormatToolFeedbackMessage(toolName, explanation string) string {
|
||||
// FormatToolFeedbackMessage renders a tool feedback message for chat channels.
|
||||
// It keeps the tool name on the first line for animation and can include both
|
||||
// a human explanation and the serialized tool arguments in the body.
|
||||
func FormatToolFeedbackMessage(toolName, explanation, argsPreview string) string {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
explanation = strings.TrimSpace(explanation)
|
||||
argsPreview = strings.TrimSpace(argsPreview)
|
||||
|
||||
bodyLines := make([]string, 0, 2)
|
||||
if explanation != "" {
|
||||
bodyLines = append(bodyLines, explanation)
|
||||
}
|
||||
if argsPreview != "" {
|
||||
bodyLines = append(bodyLines, "```json\n"+argsPreview+"\n```")
|
||||
}
|
||||
body := strings.Join(bodyLines, "\n")
|
||||
|
||||
if toolName == "" {
|
||||
return explanation
|
||||
return body
|
||||
}
|
||||
if explanation == "" {
|
||||
if body == "" {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -6,29 +6,38 @@ func TestFormatToolFeedbackMessage(t *testing.T) {
|
|||
got := FormatToolFeedbackMessage(
|
||||
"read_file",
|
||||
"I will read README.md first to confirm the current project structure.",
|
||||
"{\n \"path\": \"README.md\"\n}",
|
||||
)
|
||||
want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure."
|
||||
want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure.\n```json\n{\n \"path\": \"README.md\"\n}\n```"
|
||||
if got != want {
|
||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) {
|
||||
got := FormatToolFeedbackMessage("read_file", "")
|
||||
want := "\U0001f527 `read_file`"
|
||||
func TestFormatToolFeedbackMessage_EmptyExplanationShowsArgs(t *testing.T) {
|
||||
got := FormatToolFeedbackMessage("read_file", "", "{\n \"path\": \"README.md\"\n}")
|
||||
want := "\U0001f527 `read_file`\n```json\n{\n \"path\": \"README.md\"\n}\n```"
|
||||
if got != want {
|
||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) {
|
||||
got := FormatToolFeedbackMessage("", "Continue drafting the final response.")
|
||||
got := FormatToolFeedbackMessage("", "Continue drafting the final response.", "")
|
||||
want := "Continue drafting the final response."
|
||||
if got != want {
|
||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatToolFeedbackMessage_EmptyExplanationAndArgsKeepsOnlyToolLine(t *testing.T) {
|
||||
got := FormatToolFeedbackMessage("read_file", "", "")
|
||||
want := "\U0001f527 `read_file`"
|
||||
if got != want {
|
||||
t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) {
|
||||
got := FitToolFeedbackMessage(
|
||||
"\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.",
|
||||
|
|
|
|||
186
scripts/copydir.go
Normal file
186
scripts/copydir.go
Normal 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()
|
||||
}
|
||||
90
web/Makefile
90
web/Makefile
|
|
@ -2,15 +2,24 @@
|
|||
build-android-arm64 build-android-bundle
|
||||
|
||||
# Go variables
|
||||
GO?=CGO_ENABLED=0 go
|
||||
GO?=go
|
||||
WEB_GO?=$(GO)
|
||||
CGO_ENABLED?=0
|
||||
GO_BUILD_TAGS?=goolm,stdjson
|
||||
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
|
||||
GOCACHE?=$(abspath ../.cache/go-build)
|
||||
GOMODCACHE?=$(abspath ../.cache/go-mod)
|
||||
GOTOOLCHAIN?=local
|
||||
export CGO_ENABLED
|
||||
export GOCACHE
|
||||
export GOMODCACHE
|
||||
export GOTOOLCHAIN
|
||||
|
||||
# Build variables
|
||||
BUILD_DIR=build
|
||||
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
|
||||
OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64
|
||||
EXT=
|
||||
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT)
|
||||
OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT)
|
||||
FRONTEND_DIR=frontend
|
||||
FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp
|
||||
BACKEND_DIR=backend
|
||||
|
|
@ -19,18 +28,47 @@ PICOCLAW_BINARY_NAME=picoclaw
|
|||
PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME))
|
||||
LAUNCHER_GUI_LDFLAG=
|
||||
|
||||
ifeq ($(OS),Windows_NT)
|
||||
POWERSHELL=powershell -NoProfile -Command
|
||||
WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL))
|
||||
endif
|
||||
|
||||
# Version
|
||||
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME=$(shell date +%FT%T%z)
|
||||
GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}')
|
||||
ifeq ($(OS),Windows_NT)
|
||||
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL))
|
||||
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL))
|
||||
BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'"))
|
||||
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL))
|
||||
else
|
||||
VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null))
|
||||
GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null))
|
||||
BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z))
|
||||
GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null))
|
||||
endif
|
||||
VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev)
|
||||
GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev)
|
||||
BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev)
|
||||
GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown)
|
||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||
|
||||
|
||||
# OS detection
|
||||
UNAME_S:=$(shell uname -s)
|
||||
UNAME_M:=$(shell uname -m)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
UNAME_S=Windows
|
||||
ifeq ($(WINDOWS_GOARCH_RAW),amd64)
|
||||
UNAME_M=x86_64
|
||||
else ifeq ($(WINDOWS_GOARCH_RAW),arm64)
|
||||
UNAME_M=arm64
|
||||
else ifeq ($(WINDOWS_GOARCH_RAW),386)
|
||||
UNAME_M=x86
|
||||
else
|
||||
UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64)
|
||||
endif
|
||||
else
|
||||
UNAME_S:=$(shell uname -s)
|
||||
UNAME_M:=$(shell uname -m)
|
||||
endif
|
||||
|
||||
# Platform-specific settings
|
||||
ifeq ($(UNAME_S),Linux)
|
||||
|
|
@ -62,7 +100,14 @@ else ifeq ($(UNAME_S),Darwin)
|
|||
endif
|
||||
else ifeq ($(UNAME_S),Windows)
|
||||
PLATFORM=windows
|
||||
ARCH=$(UNAME_M)
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
ARCH=amd64
|
||||
else ifeq ($(UNAME_M),arm64)
|
||||
ARCH=arm64
|
||||
else
|
||||
ARCH=$(UNAME_M)
|
||||
endif
|
||||
EXT=.exe
|
||||
PICOCLAW_BINARY_NAME=picoclaw.exe
|
||||
LAUNCHER_GUI_LDFLAG=-H=windowsgui
|
||||
else
|
||||
|
|
@ -91,21 +136,36 @@ dev-backend:
|
|||
|
||||
# Build frontend and embed into Go binary
|
||||
build: build-frontend
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(OUTPUT)') | Out-Null"
|
||||
else
|
||||
@mkdir -p "$$(dirname "$(OUTPUT)")"
|
||||
endif
|
||||
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
|
||||
|
||||
# Build launcher for Android ARM64 (frontend must already be built)
|
||||
build-android-arm64: build-frontend
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||
else
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
endif
|
||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/
|
||||
|
||||
# Build launcher for all Android architectures
|
||||
build-android-bundle: build-frontend
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null"
|
||||
else
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
endif
|
||||
GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/
|
||||
@echo "All Android launcher builds complete"
|
||||
|
||||
build-frontend:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "if ((-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules/.bin/tsc')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_INSTALL_STAMP)')) -or ((Get-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Raw).Trim() -ne (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)))) { Write-Host 'Installing frontend dependencies...'; Push-Location '$(FRONTEND_DIR)'; try { pnpm install --frozen-lockfile } finally { Pop-Location }; Set-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Value (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)) -NoNewline }"
|
||||
else
|
||||
@expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \
|
||||
if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
|
||||
[ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \
|
||||
|
|
@ -115,12 +175,17 @@ build-frontend:
|
|||
(cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \
|
||||
printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \
|
||||
fi
|
||||
endif
|
||||
@echo "Building frontend..."
|
||||
@cd $(FRONTEND_DIR) && pnpm build:backend
|
||||
|
||||
build-dev-picoclaw:
|
||||
@echo "Building picoclaw for launcher development..."
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(PICOCLAW_BINARY)') | Out-Null"
|
||||
else
|
||||
@mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
|
||||
endif
|
||||
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
|
||||
|
||||
# Run all tests
|
||||
|
|
@ -135,5 +200,10 @@ lint:
|
|||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@$(POWERSHELL) "$$paths=@('$(FRONTEND_DIR)/dist','$(BACKEND_DIST)','$(BUILD_DIR)'); foreach($$p in $$paths){ if (Test-Path -LiteralPath $$p) { Remove-Item -LiteralPath $$p -Recurse -Force } }"
|
||||
@node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
|
||||
else
|
||||
rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
|
||||
node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs
|
||||
endif
|
||||
|
|
|
|||
11
web/backend/api/exec_nonwindows.go
Normal file
11
web/backend/api/exec_nonwindows.go
Normal 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) {}
|
||||
24
web/backend/api/exec_windows.go
Normal file
24
web/backend/api/exec_windows.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 }`,
|
||||
pid,
|
||||
)
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
|
||||
out, err := launcherExecCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output()
|
||||
if err == nil {
|
||||
cmdline := strings.TrimSpace(string(out))
|
||||
if cmdline != "" {
|
||||
|
|
@ -173,7 +173,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
|
|||
}
|
||||
|
||||
// Fallback: determine only whether the process still exists.
|
||||
out, err = exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
|
||||
out, err = launcherExecCommand("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
|
|
@ -187,7 +187,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
|
|||
if strings.Contains(line, "\"picoclaw.exe\"") {
|
||||
return true, true
|
||||
}
|
||||
return false, false
|
||||
return false, true
|
||||
}
|
||||
if strings.Contains(line, "no tasks are running") {
|
||||
return false, true
|
||||
|
|
@ -195,7 +195,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
|
|||
return false, true
|
||||
}
|
||||
|
||||
out, err := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output()
|
||||
out, err := launcherExecCommand("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output()
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
|
|
@ -706,6 +706,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
|||
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
|
||||
|
||||
cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...)
|
||||
applyLauncherProcAttrs(cmd)
|
||||
cmd.Env = os.Environ()
|
||||
// Forward the launcher's config path via the environment variable that
|
||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
// Skills and tools support/actions
|
||||
h.registerSkillRoutes(mux)
|
||||
h.registerToolRoutes(mux)
|
||||
h.registerUIRoutes(mux)
|
||||
|
||||
// OS startup / launch-at-login
|
||||
h.registerStartupRoutes(mux)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
|
@ -15,6 +16,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/memory"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
|
@ -48,6 +50,7 @@ type sessionListItem struct {
|
|||
type sessionChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Media []string `json:"media,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 {
|
||||
continue
|
||||
}
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
|
|
@ -473,6 +479,18 @@ func sessionChatMessagePreview(msg sessionChatMessage) string {
|
|||
}
|
||||
|
||||
func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
|
||||
return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, false)
|
||||
}
|
||||
|
||||
func detailSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage {
|
||||
return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, true)
|
||||
}
|
||||
|
||||
func sessionTranscriptMessages(
|
||||
messages []providers.Message,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
includeThoughts bool,
|
||||
) []sessionChatMessage {
|
||||
transcript := make([]sessionChatMessage, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
|
|
@ -494,11 +512,14 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
|
|||
}
|
||||
|
||||
case "assistant":
|
||||
// Reasoning-only assistant messages are transient display artifacts and
|
||||
// should not be restored from session history.
|
||||
if assistantMessageTransientThought(msg) {
|
||||
if messageutil.IsTransientAssistantThoughtMessage(msg) {
|
||||
continue
|
||||
}
|
||||
if includeThoughts {
|
||||
if thoughtMsg, ok := assistantThoughtMessage(msg); ok {
|
||||
transcript = append(transcript, thoughtMsg)
|
||||
}
|
||||
}
|
||||
|
||||
toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength)
|
||||
if len(toolSummaryMessages) > 0 {
|
||||
|
|
@ -593,7 +614,15 @@ func toolSummaryContainsContent(summary, content string) bool {
|
|||
}
|
||||
|
||||
_, 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 {
|
||||
|
|
@ -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 {
|
||||
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(
|
||||
toolCalls []providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
|
|
@ -714,7 +750,8 @@ func visibleAssistantToolSummaryMessages(
|
|||
Role: "assistant",
|
||||
Content: utils.FormatToolFeedbackMessage(
|
||||
name,
|
||||
visibleAssistantToolSummaryText(tc, toolFeedbackMaxArgsLength),
|
||||
visibleAssistantToolFeedbackExplanation(tc, toolFeedbackMaxArgsLength),
|
||||
visibleAssistantToolArgsPreview(tc, toolFeedbackMaxArgsLength),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
|
@ -722,7 +759,7 @@ func visibleAssistantToolSummaryMessages(
|
|||
return messages
|
||||
}
|
||||
|
||||
func visibleAssistantToolSummaryText(
|
||||
func visibleAssistantToolFeedbackExplanation(
|
||||
tc providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
) string {
|
||||
|
|
@ -731,18 +768,32 @@ func visibleAssistantToolSummaryText(
|
|||
return utils.Truncate(explanation, toolFeedbackMaxArgsLength)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func visibleAssistantToolArgsPreview(
|
||||
tc providers.ToolCall,
|
||||
toolFeedbackMaxArgsLength int,
|
||||
) string {
|
||||
argsJSON := ""
|
||||
if tc.Function != nil {
|
||||
argsJSON = tc.Function.Arguments
|
||||
}
|
||||
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 = 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 {
|
||||
|
|
@ -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")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"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) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
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)
|
||||
defer cleanup()
|
||||
|
||||
|
|
@ -460,6 +519,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) {
|
|||
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 {
|
||||
|
|
@ -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) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
|
@ -1056,8 +1290,11 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T)
|
|||
if !strings.Contains(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) {
|
||||
t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content)
|
||||
wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
||||
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`") {
|
||||
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))
|
||||
}
|
||||
|
||||
wantPreview := utils.Truncate(argsJSON, 20)
|
||||
wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{
|
||||
Function: &providers.FunctionCall{Arguments: argsJSON},
|
||||
}, 20)
|
||||
if !strings.Contains(resp.Messages[1].Content, "`read_file`") {
|
||||
t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
picotools "github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
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.Tools.Web.Provider = "auto"
|
||||
cfg.Tools.Web.Sogou.Enabled = true
|
||||
cfg.Tools.Web.DuckDuckGo.Enabled = true
|
||||
|
||||
picotools.SetPreferredWebSearchLanguage("en")
|
||||
t.Cleanup(func() {
|
||||
picotools.SetPreferredWebSearchLanguage("")
|
||||
})
|
||||
|
||||
if got := resolveCurrentWebSearchProvider(cfg); got != "duckduckgo" {
|
||||
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want duckduckgo", got)
|
||||
}
|
||||
|
||||
picotools.SetPreferredWebSearchLanguage("zh")
|
||||
if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" {
|
||||
t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
"github.com/sipeed/picoclaw/web/backend/api"
|
||||
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
|
|
@ -409,7 +408,6 @@ func main() {
|
|||
if *lang != "" {
|
||||
SetLanguage(*lang)
|
||||
}
|
||||
tools.SetPreferredWebSearchLanguage(string(GetLanguage()))
|
||||
|
||||
// Resolve config path
|
||||
configPath := utils.GetDefaultConfigPath()
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@
|
|||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next": "^26.0.7",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jotai": "^2.19.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-i18next": "^17.0.3",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.8"
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
411
web/frontend/pnpm-lock.yaml
generated
411
web/frontend/pnpm-lock.yaml
generated
|
|
@ -16,7 +16,7 @@ importers:
|
|||
version: 3.41.1(react@19.2.5)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(vite@8.0.8(@types/node@25.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':
|
||||
specifier: ^5.99.0
|
||||
version: 5.99.0(react@19.2.5)
|
||||
|
|
@ -39,8 +39,8 @@ importers:
|
|||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
i18next:
|
||||
specifier: ^26.0.3
|
||||
version: 26.0.3(typescript@5.9.3)
|
||||
specifier: ^26.0.7
|
||||
version: 26.0.7(typescript@5.9.3)
|
||||
i18next-browser-languagedetector:
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1
|
||||
|
|
@ -57,8 +57,8 @@ importers:
|
|||
specifier: 19.2.5
|
||||
version: 19.2.5(react@19.2.5)
|
||||
react-i18next:
|
||||
specifier: ^17.0.3
|
||||
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)
|
||||
specifier: ^17.0.4
|
||||
version: 17.0.4(i18next@26.0.7(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
|
||||
|
|
@ -104,7 +104,7 @@ importers:
|
|||
version: 0.5.19(tailwindcss@4.2.2)
|
||||
'@tanstack/router-plugin':
|
||||
specifier: ^1.164.0
|
||||
version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.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':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(prettier@3.8.3)
|
||||
|
|
@ -119,10 +119,10 @@ importers:
|
|||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.58.2
|
||||
version: 8.58.2(@typescript-eslint/parser@8.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':
|
||||
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:
|
||||
specifier: ^10.2.1
|
||||
version: 10.2.1(jiti@2.6.1)
|
||||
|
|
@ -148,11 +148,11 @@ importers:
|
|||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
typescript-eslint:
|
||||
specifier: ^8.58.2
|
||||
version: 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: ^8.59.0
|
||||
version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^8.0.8
|
||||
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
specifier: ^8.0.10
|
||||
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
|
||||
packages:
|
||||
|
||||
|
|
@ -299,11 +299,11 @@ packages:
|
|||
peerDependencies:
|
||||
'@noble/ciphers': ^1.0.0
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
'@emnapi/runtime@1.9.2':
|
||||
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
|
|
@ -608,8 +608,8 @@ packages:
|
|||
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.3':
|
||||
resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==}
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
|
@ -650,8 +650,8 @@ packages:
|
|||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@oxc-project/types@0.124.0':
|
||||
resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==}
|
||||
'@oxc-project/types@0.127.0':
|
||||
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
|
@ -1343,103 +1343,103 @@ packages:
|
|||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==}
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==}
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==}
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==}
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==}
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==}
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==}
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==}
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==}
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==}
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==}
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==}
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==}
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==}
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.15':
|
||||
resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==}
|
||||
'@rolldown/pluginutils@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||
|
|
@ -1736,8 +1736,16 @@ packages:
|
|||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/parser@8.58.2':
|
||||
resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==}
|
||||
'@typescript-eslint/eslint-plugin@8.59.0':
|
||||
resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.59.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/parser@8.59.0':
|
||||
resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
|
|
@ -1749,16 +1757,32 @@ packages:
|
|||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/scope-manager@8.59.0':
|
||||
resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.58.2':
|
||||
resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.59.0':
|
||||
resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.58.2':
|
||||
resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -1766,16 +1790,33 @@ packages:
|
|||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.59.0':
|
||||
resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/types@8.59.0':
|
||||
resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.58.2':
|
||||
resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.59.0':
|
||||
resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/utils@8.58.2':
|
||||
resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -1783,10 +1824,21 @@ packages:
|
|||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/utils@8.59.0':
|
||||
resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.58.2':
|
||||
resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.59.0':
|
||||
resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
|
|
@ -2544,8 +2596,8 @@ packages:
|
|||
i18next-browser-languagedetector@8.2.1:
|
||||
resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==}
|
||||
|
||||
i18next@26.0.3:
|
||||
resolution: {integrity: sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==}
|
||||
i18next@26.0.7:
|
||||
resolution: {integrity: sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
|
|
@ -3233,10 +3285,6 @@ packages:
|
|||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -3357,8 +3405,8 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.2.5
|
||||
|
||||
react-i18next@17.0.3:
|
||||
resolution: {integrity: sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==}
|
||||
react-i18next@17.0.4:
|
||||
resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.0.1'
|
||||
react: '>= 16.8.0'
|
||||
|
|
@ -3474,8 +3522,8 @@ packages:
|
|||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rolldown@1.0.0-rc.15:
|
||||
resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==}
|
||||
rolldown@1.0.0-rc.17:
|
||||
resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -3736,8 +3784,8 @@ packages:
|
|||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typescript-eslint@8.58.2:
|
||||
resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==}
|
||||
typescript-eslint@8.59.0:
|
||||
resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
|
|
@ -3872,8 +3920,8 @@ packages:
|
|||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@8.0.8:
|
||||
resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==}
|
||||
vite@8.0.10:
|
||||
resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
|
@ -4212,13 +4260,13 @@ snapshots:
|
|||
dependencies:
|
||||
'@noble/ciphers': 1.3.0
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.9.2':
|
||||
'@emnapi/runtime@1.10.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
|
@ -4451,10 +4499,10 @@ snapshots:
|
|||
outvariant: 1.4.3
|
||||
strict-event-emitter: 0.5.1
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)':
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.9.2
|
||||
'@emnapi/runtime': 1.9.2
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
|
|
@ -4489,7 +4537,7 @@ snapshots:
|
|||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@oxc-project/types@0.124.0': {}
|
||||
'@oxc-project/types@0.127.0': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
|
|
@ -5238,56 +5286,56 @@ snapshots:
|
|||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.15':
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.15':
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.15':
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.15':
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15':
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15':
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.15':
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15':
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15':
|
||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.15':
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.15':
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.15':
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.15':
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.17':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.9.2
|
||||
'@emnapi/runtime': 1.9.2
|
||||
'@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15':
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.15':
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.15': {}
|
||||
'@rolldown/pluginutils@1.0.0-rc.17': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
|
||||
|
|
@ -5368,12 +5416,12 @@ snapshots:
|
|||
postcss-selector-parser: 6.0.10
|
||||
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:
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 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': {}
|
||||
|
||||
|
|
@ -5446,7 +5494,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.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:
|
||||
'@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
|
||||
optionalDependencies:
|
||||
'@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:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -5558,10 +5606,10 @@ snapshots:
|
|||
|
||||
'@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:
|
||||
'@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/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)
|
||||
|
|
@ -5574,12 +5622,28 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/scope-manager': 8.58.2
|
||||
'@typescript-eslint/types': 8.58.2
|
||||
'@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.58.2
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.59.0
|
||||
'@typescript-eslint/type-utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.59.0
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
ignore: 7.0.5
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/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
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
|
|
@ -5588,8 +5652,17 @@ snapshots:
|
|||
|
||||
'@typescript-eslint/project-service@8.58.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.58.2
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.59.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.59.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.59.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -5600,10 +5673,19 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.58.2
|
||||
'@typescript-eslint/visitor-keys': 8.58.2
|
||||
|
||||
'@typescript-eslint/scope-manager@8.59.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.0
|
||||
'@typescript-eslint/visitor-keys': 8.59.0
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.58.2
|
||||
|
|
@ -5616,8 +5698,22 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/type-utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.0
|
||||
'@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/types@8.58.2': {}
|
||||
|
||||
'@typescript-eslint/types@8.59.0': {}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.58.2(typescript@5.9.3)
|
||||
|
|
@ -5633,6 +5729,21 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
|
||||
|
|
@ -5644,17 +5755,33 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
|
||||
'@typescript-eslint/scope-manager': 8.59.0
|
||||
'@typescript-eslint/types': 8.59.0
|
||||
'@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.58.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.58.2
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.59.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.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:
|
||||
'@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:
|
||||
dependencies:
|
||||
|
|
@ -6469,9 +6596,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
|
||||
i18next@26.0.3(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
i18next@26.0.7(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
|
|
@ -7267,12 +7392,6 @@ snapshots:
|
|||
picocolors: 1.1.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: {}
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
|
@ -7386,11 +7505,11 @@ snapshots:
|
|||
react: 19.2.5
|
||||
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:
|
||||
'@babel/runtime': 7.29.2
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 26.0.3(typescript@5.9.3)
|
||||
i18next: 26.0.7(typescript@5.9.3)
|
||||
react: 19.2.5
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
optionalDependencies:
|
||||
|
|
@ -7535,26 +7654,26 @@ snapshots:
|
|||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rolldown@1.0.0-rc.15:
|
||||
rolldown@1.0.0-rc.17:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.124.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.15
|
||||
'@oxc-project/types': 0.127.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.17
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.15
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.15
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.15
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.15
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.15
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.15
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.17
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.17
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.17
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.17
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.17
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.17
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
|
|
@ -7848,12 +7967,12 @@ snapshots:
|
|||
media-typer: 1.1.0
|
||||
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:
|
||||
'@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/parser': 8.58.2(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/utils': 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)
|
||||
'@typescript-eslint/parser': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 10.2.1(jiti@2.6.1)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -7985,12 +8104,12 @@ snapshots:
|
|||
'@types/unist': 3.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:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.9
|
||||
rolldown: 1.0.0-rc.15
|
||||
postcss: 8.5.10
|
||||
rolldown: 1.0.0-rc.17
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface SessionDetail {
|
|||
messages: {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
kind?: "normal" | "thought"
|
||||
media?: string[]
|
||||
attachments?: {
|
||||
type?: "image" | "audio" | "video" | "file"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
IconDownload,
|
||||
IconFileText,
|
||||
} from "@tabler/icons-react"
|
||||
import { useAtom } from "jotai"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
|
|
@ -18,7 +17,7 @@ import remarkGfm from "remark-gfm"
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { formatMessageTime } from "@/hooks/use-pico-chat"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type ChatAttachment, showThoughtsAtom } from "@/store/chat"
|
||||
import { type ChatAttachment } from "@/store/chat"
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string
|
||||
|
|
@ -42,7 +41,7 @@ export function AssistantMessage({
|
|||
const fileAttachments = attachments.filter(
|
||||
(attachment) => attachment.type !== "image",
|
||||
)
|
||||
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const formattedTimestamp =
|
||||
timestamp !== "" ? formatMessageTime(timestamp) : ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { IconPlus } from "@tabler/icons-react"
|
||||
import { useAtom } from "jotai"
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -15,12 +16,14 @@ import { TypingIndicator } from "@/components/chat/typing-indicator"
|
|||
import { UserMessage } from "@/components/chat/user-message"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useChatModels } from "@/hooks/use-chat-models"
|
||||
import { useGateway } from "@/hooks/use-gateway"
|
||||
import { usePicoChat } from "@/hooks/use-pico-chat"
|
||||
import { useSessionHistory } from "@/hooks/use-session-history"
|
||||
import type { ConnectionState } from "@/store/chat"
|
||||
import type { ChatAttachment } from "@/store/chat"
|
||||
import { showThoughtsAtom } from "@/store/chat"
|
||||
import type { GatewayState } from "@/store/gateway"
|
||||
|
||||
const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024
|
||||
|
|
@ -109,6 +112,7 @@ export function ChatPage() {
|
|||
const [hasScrolled, setHasScrolled] = useState(false)
|
||||
const [input, setInput] = useState("")
|
||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||
const [showThoughts, setShowThoughts] = useAtom(showThoughtsAtom)
|
||||
|
||||
const {
|
||||
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
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
@ -306,23 +322,29 @@ export function ChatPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className="flex w-full">
|
||||
{msg.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
isThought={msg.kind === "thought"}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{messages.map((msg) => {
|
||||
if (msg.kind === "thought" && !showThoughts) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="flex w-full">
|
||||
{msg.role === "assistant" ? (
|
||||
<AssistantMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
isThought={msg.kind === "thought"}
|
||||
timestamp={msg.timestamp}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage
|
||||
content={msg.content}
|
||||
attachments={msg.attachments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{isTyping && <TypingIndicator />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -244,6 +244,7 @@ export function ConfigPage() {
|
|||
tool_feedback: {
|
||||
enabled: form.toolFeedbackEnabled,
|
||||
max_args_length: toolFeedbackMaxArgsLength,
|
||||
separate_messages: form.toolFeedbackSeparateMessages,
|
||||
},
|
||||
max_tokens: maxTokens,
|
||||
context_window: contextWindow,
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<Field
|
||||
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
Loading…
Add table
Reference in a new issue