Merge branch 'sipeed:main' into main

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

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
# Ensure shell scripts always use LF line endings regardless of OS.
*.sh text eol=lf
docker/entrypoint.sh text eol=lf

View file

@ -74,7 +74,10 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- 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)"

View file

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

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

View file

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

View file

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

View file

@ -571,7 +571,20 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a
}
```
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool).
You can manage common MCP setups directly from the CLI instead of editing JSON by hand:
```bash
picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
picoclaw mcp list
picoclaw mcp test filesystem
```
`picoclaw mcp` is a configuration manager: it updates `config.json` under `tools.mcp.servers`, but it does not keep the server process running itself.
Use `picoclaw mcp edit` when you need advanced fields that are not covered by `picoclaw mcp add`.
For example, `picoclaw mcp add` supports `--deferred` and `--env-file`, while `picoclaw mcp edit` is still useful for direct JSON editing and uncommon MCP settings.
For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). For CLI usage and examples, see [MCP Server CLI](docs/reference/mcp-cli.md).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
@ -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

View file

@ -0,0 +1,384 @@
package cliui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/lipgloss"
)
// MCPShowServer holds the server metadata for PrintMCPShow.
type MCPShowServer struct {
Name string
Type string
Target string
Enabled bool
EffectiveDeferred bool // resolved value (per-server override or global default)
DeferredExplicit bool // true = per-server override set, false = inherited from global
EnvKeys []string // sorted env var names (values intentionally omitted)
EnvFile string
Headers []string // sorted header names
}
// MCPShowTool holds one tool's info for PrintMCPShow.
type MCPShowTool struct {
Name string
Description string
Parameters []MCPShowParam
}
// MCPShowParam is one parameter entry.
type MCPShowParam struct {
Name string
Type string
Description string
Required bool
}
// PrintMCPShow renders the mcp show output (plain or fancy).
// w is where the output is written; pass cmd.OutOrStdout() from cobra commands.
func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
if !UseFancyLayout() {
printMCPShowPlain(w, server, tools, disabled)
return
}
printMCPShowFancy(w, server, tools, disabled)
}
// ── plain (narrow / non-TTY) ────────────────────────────────────────────────
func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
fmt.Fprintf(w, "Server: %s\n", server.Name)
fmt.Fprintf(w, "Type: %s\n", server.Type)
fmt.Fprintf(w, "Target: %s\n", server.Target)
fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled))
deferredLabel := boolWord(server.EffectiveDeferred)
if !server.DeferredExplicit {
deferredLabel += " (default)"
}
fmt.Fprintf(w, "Deferred: %s\n", deferredLabel)
if len(server.EnvKeys) > 0 {
fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", "))
}
if server.EnvFile != "" {
fmt.Fprintf(w, "Env file: %s\n", server.EnvFile)
}
if len(server.Headers) > 0 {
fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", "))
}
fmt.Fprintln(w)
if disabled {
fmt.Fprintln(w, "Server is disabled; skipping tool discovery.")
return
}
if len(tools) == 0 {
fmt.Fprintln(w, "No tools exposed by this server.")
return
}
fmt.Fprintf(w, "Tools (%d):\n", len(tools))
for _, tool := range tools {
fmt.Fprintf(w, " %s\n", tool.Name)
if tool.Description != "" {
fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120))
}
if len(tool.Parameters) == 0 {
fmt.Fprintln(w, " Parameters: none")
continue
}
for _, p := range tool.Parameters {
line := fmt.Sprintf(" - %s", p.Name)
if p.Type != "" {
line += fmt.Sprintf(" (%s", p.Type)
if p.Required {
line += ", required"
}
line += ")"
} else if p.Required {
line += " (required)"
}
if p.Description != "" {
line += ": " + truncateDescription(p.Description, 80)
}
fmt.Fprintln(w, line)
}
}
}
// ── fancy (wide TTY) ────────────────────────────────────────────────────────
var (
mcpToolNameStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentBlue).Bold(true)
}
mcpParamNameStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(accentRed).Bold(true)
}
mcpTagStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
}
mcpRequiredStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
}
mcpOptionalStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
}
mcpDescStyle = func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
}
)
func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) {
inner := InnerWidth()
box := borderStyle().Width(inner)
var b strings.Builder
// ── server header ──
b.WriteString(titleBarStyle().Render("⬡ " + server.Name))
b.WriteString("\n\n")
keyW := 10
writeKV := func(key, val string) {
k := kvKeyStyle().Width(keyW).Render(key)
b.WriteString(k + " " + val + "\n")
}
writeKV("Type", server.Type)
writeKV("Target", server.Target)
writeKV("Enabled", coloredBool(server.Enabled))
deferredVal := coloredBool(server.EffectiveDeferred)
if !server.DeferredExplicit {
deferredVal += " " + mcpTagStyle().Render("(default)")
}
writeKV("Deferred", deferredVal)
if len(server.EnvKeys) > 0 {
writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", ")))
}
if server.EnvFile != "" {
writeKV("Env file", mutedStyle().Render(server.EnvFile))
}
if len(server.Headers) > 0 {
writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", ")))
}
if disabled {
b.WriteString("\n")
b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery."))
fmt.Fprintln(w, box.Render(b.String()))
return
}
if len(tools) == 0 {
b.WriteString("\n")
b.WriteString(mutedStyle().Render("No tools exposed by this server."))
fmt.Fprintln(w, box.Render(b.String()))
return
}
// ── tools section ──
b.WriteString("\n")
b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools))))
b.WriteString("\n")
contentW := inner - 4 // account for box padding
for i, tool := range tools {
if i > 0 {
b.WriteString(strings.Repeat("─", contentW) + "\n")
}
b.WriteString("\n")
// Tool name + index badge
badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools)))
b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n")
// Description (wrapped to content width)
if tool.Description != "" {
desc := truncateDescription(tool.Description, 160)
b.WriteString(" " + mcpDescStyle().Render(desc) + "\n")
}
// Parameters
if len(tool.Parameters) == 0 {
b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n")
continue
}
b.WriteString("\n")
for _, p := range tool.Parameters {
// name
pName := mcpParamNameStyle().Render(p.Name)
// type tag
typeTag := ""
if p.Type != "" {
typeTag = " " + mcpTagStyle().Render("<"+p.Type+">")
}
// required / optional badge
var reqBadge string
if p.Required {
reqBadge = " " + mcpRequiredStyle().Render("required")
} else {
reqBadge = " " + mcpOptionalStyle().Render("optional")
}
b.WriteString(" " + pName + typeTag + reqBadge + "\n")
if p.Description != "" {
desc := truncateDescription(p.Description, 120)
b.WriteString(" " + mutedStyle().Render(desc) + "\n")
}
}
}
fmt.Fprintln(w, box.Render(b.String()))
}
// ── mcp list ────────────────────────────────────────────────────────────────
// MCPListRow is one row in the mcp list output.
type MCPListRow struct {
Name string
Type string
Target string
Status string // "enabled", "disabled", "ok (N tools)", "error"
EffectiveDeferred bool // resolved value (per-server override or global default)
DeferredExplicit bool // true = per-server override set, false = inherited from global
}
// PrintMCPList renders the mcp list output (plain or fancy).
func PrintMCPList(w io.Writer, rows []MCPListRow) {
if !UseFancyLayout() {
printMCPListPlain(w, rows)
return
}
printMCPListFancy(w, rows)
}
func printMCPListPlain(w io.Writer, rows []MCPListRow) {
headers := []string{"Name", "Type", "Command", "Status", "Deferred"}
tableRows := make([][]string, len(rows))
for i, r := range rows {
deferred := boolWord(r.EffectiveDeferred)
if !r.DeferredExplicit {
deferred += " (default)"
}
tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred}
}
// reuse the ASCII table renderer already in helpers.go via the caller
// (list.go still uses renderTable for the plain path)
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range tableRows {
for i, cell := range row {
if len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
border := func() {
fmt.Fprint(w, "+")
for _, width := range widths {
fmt.Fprint(w, strings.Repeat("-", width+2)+"+")
}
fmt.Fprintln(w)
}
writeRow := func(row []string) {
fmt.Fprint(w, "|")
for i, cell := range row {
fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell)))
}
fmt.Fprintln(w)
}
border()
writeRow(headers)
border()
for _, row := range tableRows {
writeRow(row)
}
border()
}
func printMCPListFancy(w io.Writer, rows []MCPListRow) {
inner := InnerWidth()
box := borderStyle().Width(inner)
var b strings.Builder
title := fmt.Sprintf("MCP Servers (%d)", len(rows))
b.WriteString(titleBarStyle().Render(title))
b.WriteString("\n")
contentW := inner - 4
for i, row := range rows {
if i > 0 {
b.WriteString(strings.Repeat("─", contentW) + "\n")
}
b.WriteString("\n")
statusBadge := mcpListStatusStyle(row.Status).Render(row.Status)
var deferredBadge string
if row.EffectiveDeferred {
if row.DeferredExplicit {
deferredBadge = " " + mcpTagStyle().Render("deferred")
} else {
deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)")
}
}
b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n")
b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n")
}
fmt.Fprintln(w, box.Render(b.String()))
}
func mcpListStatusStyle(status string) lipgloss.Style {
switch {
case status == "enabled":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
case status == "disabled":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B"))
case strings.HasPrefix(status, "ok"):
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true)
case status == "error":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true)
default:
return lipgloss.NewStyle()
}
}
// ── helpers ─────────────────────────────────────────────────────────────────
func boolWord(v bool) string {
if v {
return "yes"
}
return "no"
}
func coloredBool(v bool) string {
if v {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes")
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no")
}
// truncateDescription strips newlines, collapses whitespace, and caps length.
func truncateDescription(s string, maxLen int) string {
// collapse newlines and repeated spaces into a single space
s = strings.Join(strings.Fields(s), " ")
if len(s) <= maxLen {
return s
}
// cut at last space before maxLen
cut := s[:maxLen]
if idx := strings.LastIndex(cut, " "); idx > maxLen/2 {
cut = cut[:idx]
}
return cut + "…"
}

View file

@ -0,0 +1,249 @@
package mcp
import (
"fmt"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/config"
)
type addOptions struct {
Env []string
EnvFile string
Headers []string
Transport string
Force bool
Deferred *bool // nil = not set, true = deferred, false = not deferred
}
func newAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add [flags] <name> <command-or-url> [args...]",
Short: "Add or update an MCP server",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts, name, target, targetArgs, showHelp, err := parseAddArgs(args)
if showHelp {
return cmd.Help()
}
if err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
if cfg.Tools.MCP.Servers == nil {
cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig)
}
if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force {
var overwrite bool
overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name)
if err != nil {
return fmt.Errorf("failed to confirm overwrite: %w", err)
}
if !overwrite {
return fmt.Errorf("aborted: MCP server %q already exists", name)
}
}
server, err := buildServerConfig(target, targetArgs, opts)
if err != nil {
return err
}
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Servers[name] = server
if err := saveValidatedConfig(cfg); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name)
return nil
},
}
flags := cmd.Flags()
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse")
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
return cmd
}
func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) {
opts := addOptions{Transport: "stdio"}
var positional []string
serverArgs := make([]string, 0)
explicitCommand := make([]string, 0)
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--help" || arg == "-h":
return addOptions{}, "", "", nil, true, nil
case arg == "--":
if i+1 < len(args) {
explicitCommand = append(explicitCommand, args[i+1:]...)
}
i = len(args)
case arg == "--force" || arg == "-f":
opts.Force = true
case arg == "--deferred":
t := true
opts.Deferred = &t
case arg == "--no-deferred":
f := false
opts.Deferred = &f
case arg == "--transport" || arg == "-t":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Transport = args[i]
case strings.HasPrefix(arg, "--transport="):
opts.Transport = strings.TrimPrefix(arg, "--transport=")
case arg == "--env" || arg == "-e":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Env = append(opts.Env, args[i])
case arg == "--env-file":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.EnvFile = args[i]
case strings.HasPrefix(arg, "--env="):
opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env="))
case strings.HasPrefix(arg, "--env-file="):
opts.EnvFile = strings.TrimPrefix(arg, "--env-file=")
case arg == "--header" || arg == "-H":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Headers = append(opts.Headers, args[i])
case strings.HasPrefix(arg, "--header="):
opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header="))
case strings.HasPrefix(arg, "-") && len(positional) >= 2:
serverArgs = append(serverArgs, args[i:]...)
i = len(args)
default:
positional = append(positional, arg)
}
}
if len(explicitCommand) > 0 {
if len(positional) != 1 {
return addOptions{}, "", "", nil, false, fmt.Errorf(
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
)
}
if len(explicitCommand) == 0 {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --")
}
return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil
}
if len(positional) < 2 {
return addOptions{}, "", "", nil, false, fmt.Errorf(
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
)
}
targetArgs := make([]string, 0, len(positional)-2+len(serverArgs))
targetArgs = append(targetArgs, positional[2:]...)
targetArgs = append(targetArgs, serverArgs...)
return opts, positional[0], positional[1], targetArgs, false, nil
}
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
transport := strings.ToLower(strings.TrimSpace(opts.Transport))
if transport == "" {
transport = "stdio"
}
switch transport {
case "stdio", "http", "sse":
default:
return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport)
}
env, err := parseEnvAssignments(opts.Env)
if err != nil {
return config.MCPServerConfig{}, err
}
headers, err := parseHeaderAssignments(opts.Headers)
if err != nil {
return config.MCPServerConfig{}, err
}
server := config.MCPServerConfig{
Enabled: true,
Type: transport,
Deferred: opts.Deferred,
}
switch transport {
case "http", "sse":
if len(env) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport")
}
if strings.TrimSpace(opts.EnvFile) != "" {
return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport")
}
if len(args) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport)
}
parsedURL, err := url.ParseRequestURI(target)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target)
}
server.URL = target
server.Headers = headers
return server, nil
}
if len(headers) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport")
}
if looksLikeRemoteURL(target) {
return config.MCPServerConfig{}, fmt.Errorf(
"target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse",
target,
transport,
)
}
command := target
commandArgs := append([]string(nil), args...)
if err := validateLocalCommandPath(target); err != nil {
return config.MCPServerConfig{}, err
}
if isLocalCommandPath(command) {
command = expandHomePath(command)
}
server.Command = command
server.Args = commandArgs
server.Env = env
server.EnvFile = strings.TrimSpace(opts.EnvFile)
return server, nil
}

View file

@ -0,0 +1,25 @@
package mcp
import "github.com/spf13/cobra"
func NewMCPCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "Manage MCP server configuration",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cmd.AddCommand(
newAddCommand(),
newRemoveCommand(),
newListCommand(),
newEditCommand(),
newTestCommand(),
newShowCommand(),
)
return cmd
}

View file

@ -0,0 +1,619 @@
package mcp
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewMCPCommand(t *testing.T) {
cmd := NewMCPCommand()
require.NotNil(t, cmd)
assert.Equal(t, "mcp", cmd.Use)
assert.Equal(t, "Manage MCP server configuration", cmd.Short)
assert.True(t, cmd.HasSubCommands())
allowedCommands := []string{
"add",
"remove",
"list",
"edit",
"test",
"show",
}
subcommands := cmd.Commands()
assert.Len(t, subcommands, len(allowedCommands))
for _, subcmd := range subcommands {
found := slices.Contains(allowedCommands, subcmd.Name())
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
assert.False(t, subcmd.Hidden)
}
}
func TestMCPAddAddsGenericStdioServer(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{
"add",
"sqlite",
"npx",
"-y",
"@modelcontextprotocol/server-sqlite",
"--db",
"./mydb.db",
}, "")
require.NoError(t, err)
assert.Contains(t, output, `MCP server "sqlite" saved`)
cfg := readMCPConfig(t, configPath)
require.True(t, cfg.Tools.MCP.Enabled)
server, ok := cfg.Tools.MCP.Servers["sqlite"]
require.True(t, ok)
assert.True(t, server.Enabled)
assert.Equal(t, "stdio", server.Type)
assert.Equal(t, "npx", server.Command)
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args)
}
func TestMCPAddSupportsHeadersAfterURL(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"apify",
"https://mcp.apify.com/",
"-t",
"http",
"--header",
"Authorization: Bearer OMITTED",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["apify"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.apify.com/", server.URL)
assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers)
}
func TestMCPAddSupportsTransportBeforeName(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--transport",
"sse",
"fiscal-ai",
"https://api.fiscal.ai/mcp/sse",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["fiscal-ai"]
assert.Equal(t, "sse", server.Type)
assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL)
}
func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--transport",
"stdio",
"--env",
"AIRTABLE_API_KEY=YOUR_KEY",
"airtable",
"--",
"npx",
"-y",
"airtable-mcp-server",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["airtable"]
assert.Equal(t, "stdio", server.Type)
assert.Equal(t, "npx", server.Command)
assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args)
assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env)
}
func TestMCPAddSupportsEnvFileForStdio(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--env-file",
".env.mcp",
"filesystem",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["filesystem"]
assert.Equal(t, "stdio", server.Type)
assert.Equal(t, "npx", server.Command)
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args)
assert.Equal(t, ".env.mcp", server.EnvFile)
}
func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) {
setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--transport",
"http",
"--env-file",
".env.mcp",
"context7",
"https://mcp.context7.com/mcp",
}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport")
}
func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) {
setupMCPConfigEnv(t)
tmpDir := t.TempDir()
localCmd := filepath.Join(tmpDir, "server.sh")
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644))
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "local", localCmd}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "not executable")
}
func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) {
configPath := setupMCPConfigEnv(t)
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
localCmd := filepath.Join(homeDir, "bin", "my-mcp")
require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755))
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755))
tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["local-home"]
assert.Equal(t, localCmd, server.Command)
}
func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) {
setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), `looks like a remote MCP URL`)
assert.Contains(t, err.Error(), `Use --transport http or --transport sse`)
}
func TestMCPAddOverwritePromptDecline(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "old",
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n")
require.Error(t, err)
assert.Contains(t, output, `Overwrite? [y/N]:`)
assert.Contains(t, err.Error(), "aborted")
cfg := readMCPConfig(t, configPath)
assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command)
}
func TestMCPAddOverwriteWithConfirmation(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "old",
},
},
},
},
})
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command)
}
func TestMCPAddHTTPServer(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["context7"]
assert.Equal(t, "http", server.Type)
assert.Equal(t, "https://mcp.context7.com/mcp", server.URL)
assert.Empty(t, server.Command)
}
func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "npx",
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "")
require.NoError(t, err)
assert.Contains(t, output, `MCP server "filesystem" removed`)
cfg := readMCPConfig(t, configPath)
assert.False(t, cfg.Tools.MCP.Enabled)
assert.Empty(t, cfg.Tools.MCP.Servers)
}
func TestMCPListPrintsTable(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"context7": {
Enabled: true,
Type: "http",
URL: "https://mcp.context7.com/mcp",
},
"filesystem": {
Enabled: false,
Type: "stdio",
Command: "npx",
Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"},
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"list"}, "")
require.NoError(t, err)
assert.Contains(t, output, "| Name")
assert.Contains(t, output, "context7")
assert.Contains(t, output, "filesystem")
assert.Contains(t, output, "https://mcp.context7.com/mcp")
assert.Contains(t, output, "disabled")
}
func TestMCPListWithStatusUsesProbe(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: true,
Type: "stdio",
Command: "npx",
},
},
},
},
})
originalProbe := serverProbe
defer func() { serverProbe = originalProbe }()
serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) {
assert.Equal(t, "filesystem", name)
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
assert.Equal(t, "npx", server.Command)
return probeResult{ToolCount: 3}, nil
}
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"list", "--status"}, "")
require.NoError(t, err)
assert.Contains(t, output, "ok (3 tools)")
}
func TestMCPEditUsesEditor(t *testing.T) {
configPath := setupMCPConfigEnv(t)
originalEditor := editorCommand
defer func() { editorCommand = originalEditor }()
var gotName string
var gotArgs []string
editorCommand = func(name string, args ...string) *exec.Cmd {
gotName = name
gotArgs = append([]string(nil), args...)
return exec.Command("sh", "-c", "exit 0")
}
t.Setenv("EDITOR", `dummy-editor --wait`)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"edit"}, "")
require.NoError(t, err)
assert.Equal(t, "dummy-editor", gotName)
assert.Equal(t, []string{"--wait", configPath}, gotArgs)
_, statErr := os.Stat(configPath)
assert.NoError(t, statErr)
}
func TestMCPEditRequiresEditor(t *testing.T) {
setupMCPConfigEnv(t)
t.Setenv("EDITOR", "")
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"edit"}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "$EDITOR is not set")
}
func TestMCPTestUsesProbe(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"filesystem": {
Enabled: false,
Type: "stdio",
Command: "npx",
},
},
},
},
})
originalProbe := serverProbe
defer func() { serverProbe = originalProbe }()
serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) {
assert.Equal(t, "filesystem", name)
assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath)
return probeResult{ToolCount: 2}, nil
}
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"test", "filesystem"}, "")
require.NoError(t, err)
assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`)
}
func TestMCPAddDeferredFlag(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["myserver"]
require.NotNil(t, server.Deferred)
assert.True(t, *server.Deferred)
}
func TestMCPAddNoDeferredFlag(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["myserver"]
require.NotNil(t, server.Deferred)
assert.False(t, *server.Deferred)
}
func TestMCPAddNoDeferredByDefault(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["myserver"]
assert.Nil(t, server.Deferred)
}
func TestMCPShowNotFound(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, nil)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"show", "missing"}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), `"missing" not found`)
}
func TestMCPShowDisabledServer(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"myserver": {
Enabled: false,
Type: "stdio",
Command: "npx",
},
},
},
},
})
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
require.NoError(t, err)
assert.Contains(t, output, "myserver")
assert.Contains(t, output, "disabled")
}
func TestMCPShowUsesProbe(t *testing.T) {
configPath := setupMCPConfigEnv(t)
writeMCPConfig(t, configPath, &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
"myserver": {
Enabled: true,
Type: "stdio",
Command: "npx",
},
},
},
},
})
original := serverShowProbe
defer func() { serverShowProbe = original }()
serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) {
assert.Equal(t, "myserver", name)
return []toolDetail{
{
Name: "read_file",
Description: "Read a file from the filesystem",
Parameters: []paramDetail{
{Name: "path", Type: "string", Description: "File path", Required: true},
{Name: "encoding", Type: "string", Description: "Character encoding", Required: false},
},
},
{
Name: "list_dir",
Description: "List directory contents",
Parameters: nil,
},
}, nil
}
cmd := NewMCPCommand()
output, err := executeCommand(cmd, []string{"show", "myserver"}, "")
require.NoError(t, err)
assert.Contains(t, output, "myserver")
assert.Contains(t, output, "read_file")
assert.Contains(t, output, "Read a file from the filesystem")
assert.Contains(t, output, "path")
assert.Contains(t, output, "string")
assert.Contains(t, output, "required")
assert.Contains(t, output, "list_dir")
assert.Contains(t, output, "none")
}
func setupMCPConfigEnv(t *testing.T) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv(config.EnvConfig, configPath)
t.Setenv(config.EnvHome, filepath.Dir(configPath))
return configPath
}
func writeMCPConfig(t *testing.T, path string, cfg *config.Config) {
t.Helper()
if cfg == nil {
cfg = config.DefaultConfig()
}
require.NoError(t, config.SaveConfig(path, cfg))
}
func readMCPConfig(t *testing.T, path string) *config.Config {
t.Helper()
cfg, err := config.LoadConfig(path)
require.NoError(t, err)
return cfg
}
func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetArgs(args)
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetIn(strings.NewReader(stdin))
err := cmd.Execute()
return stdout.String() + stderr.String(), err
}

View file

@ -0,0 +1,54 @@
package mcp
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"go.mau.fi/util/shlex"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
)
func newEditCommand() *cobra.Command {
return &cobra.Command{
Use: "edit",
Short: "Open the PicoClaw config in $EDITOR",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
editor := strings.TrimSpace(os.Getenv("EDITOR"))
if editor == "" {
return fmt.Errorf("$EDITOR is not set")
}
cfg, err := loadConfig()
if err != nil {
return err
}
if err = saveValidatedConfig(cfg); err != nil {
return err
}
editorArgs, err := shlex.Split(editor)
if err != nil {
return fmt.Errorf("failed to parse $EDITOR: %w", err)
}
if len(editorArgs) == 0 {
return fmt.Errorf("$EDITOR is empty")
}
editorArgs = append(editorArgs, internal.GetConfigPath())
process := editorCommand(editorArgs[0], editorArgs[1:]...)
process.Stdin = cmd.InOrStdin()
process.Stdout = cmd.OutOrStdout()
process.Stderr = cmd.ErrOrStderr()
if err := process.Run(); err != nil {
return fmt.Errorf("failed to start editor: %w", err)
}
return nil
},
}
}

View file

@ -0,0 +1,359 @@
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"github.com/google/jsonschema-go/jsonschema"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
)
type probeResult struct {
ToolCount int
}
var (
editorCommand = exec.Command
serverProbe = defaultServerProbe
mcpConfigSchemaOnce sync.Once
mcpConfigSchema *jsonschema.Resolved
errMcpConfigSchema error
)
const mcpConfigSchemaJSON = `{
"type": "object",
"properties": {
"tools": {
"type": "object",
"properties": {
"mcp": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"discovery": { "type": "object", "additionalProperties": true },
"max_inline_text_chars": { "type": "integer" },
"servers": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"deferred": { "type": "boolean" },
"command": { "type": "string" },
"args": {
"type": "array",
"items": { "type": "string" }
},
"env": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"env_file": { "type": "string" },
"type": {
"type": "string",
"enum": ["stdio", "http", "sse"]
},
"url": { "type": "string" },
"headers": {
"type": "object",
"additionalProperties": { "type": "string" }
}
},
"required": ["enabled"],
"anyOf": [
{ "required": ["command"] },
{ "required": ["url"] }
],
"additionalProperties": false
}
}
},
"required": ["enabled"],
"additionalProperties": true
}
},
"required": ["mcp"],
"additionalProperties": true
}
},
"required": ["tools"],
"additionalProperties": true
}`
func loadConfig() (*config.Config, error) {
cfg, err := config.LoadConfig(internal.GetConfigPath())
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return cfg, nil
}
func saveValidatedConfig(cfg *config.Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
data, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to serialize config: %w", err)
}
if err := validateConfigDocument(data); err != nil {
return err
}
if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func validateConfigDocument(data []byte) error {
var instance map[string]any
if err := json.Unmarshal(data, &instance); err != nil {
return fmt.Errorf("failed to decode serialized config: %w", err)
}
schema, err := loadMCPConfigSchema()
if err != nil {
return fmt.Errorf("failed to load MCP config schema: %w", err)
}
if err := schema.Validate(instance); err != nil {
return fmt.Errorf("config validation failed: %w", err)
}
return nil
}
func loadMCPConfigSchema() (*jsonschema.Resolved, error) {
mcpConfigSchemaOnce.Do(func() {
var schema jsonschema.Schema
if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil {
errMcpConfigSchema = err
return
}
mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil)
})
return mcpConfigSchema, errMcpConfigSchema
}
func inferTransportType(server config.MCPServerConfig) string {
switch server.Type {
case "stdio", "http", "sse":
return server.Type
}
if server.URL != "" {
return "sse"
}
if server.Command != "" {
return "stdio"
}
return "unknown"
}
func renderServerTarget(server config.MCPServerConfig) string {
transport := inferTransportType(server)
if transport == "http" || transport == "sse" {
if server.URL == "" {
return "<missing url>"
}
return server.URL
}
parts := append([]string{server.Command}, server.Args...)
rendered := strings.TrimSpace(strings.Join(parts, " "))
if rendered == "" {
return "<missing command>"
}
return rendered
}
func sortedServerNames(servers map[string]config.MCPServerConfig) []string {
names := make([]string, 0, len(servers))
for name := range servers {
names = append(names, name)
}
sort.Strings(names)
return names
}
func parseEnvAssignments(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
env := make(map[string]string, len(values))
for _, entry := range values {
key, value, found := strings.Cut(entry, "=")
if !found {
return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry)
}
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry)
}
env[key] = value
}
return env, nil
}
func parseHeaderAssignments(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
headers := make(map[string]string, len(values))
for _, entry := range values {
key, value, found := strings.Cut(entry, ":")
if !found {
key, value, found = strings.Cut(entry, "=")
}
if !found {
return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry)
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry)
}
headers[key] = value
}
return headers, nil
}
func looksLikeRemoteURL(target string) bool {
parsedURL, err := url.ParseRequestURI(target)
if err != nil {
return false
}
if parsedURL.Host == "" {
return false
}
switch strings.ToLower(parsedURL.Scheme) {
case "http", "https":
return true
default:
return false
}
}
func isLocalCommandPath(command string) bool {
if command == "" {
return false
}
if looksLikeRemoteURL(command) {
return false
}
return filepath.IsAbs(command) ||
filepath.VolumeName(command) != "" ||
strings.HasPrefix(command, "."+string(os.PathSeparator)) ||
strings.HasPrefix(command, ".."+string(os.PathSeparator)) ||
command == "." ||
command == ".." ||
strings.ContainsRune(command, os.PathSeparator)
}
func expandHomePath(path string) string {
if path == "" || path[0] != '~' {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
if path == "~" {
return home
}
if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") {
return filepath.Join(home, path[2:])
}
return path
}
func validateLocalCommandPath(command string) error {
if !isLocalCommandPath(command) {
return nil
}
path := expandHomePath(command)
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("local command %q does not exist", command)
}
return fmt.Errorf("failed to stat local command %q: %w", command, err)
}
if info.IsDir() {
return fmt.Errorf("local command %q is a directory", command)
}
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
return fmt.Errorf("local command %q is not executable", command)
}
return nil
}
func defaultServerProbe(
ctx context.Context,
name string,
server config.MCPServerConfig,
workspacePath string,
) (probeResult, error) {
mgr := picomcp.NewManager()
defer func() { _ = mgr.Close() }()
server.Enabled = true
mcpCfg := config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
name: server,
},
}
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
return probeResult{}, err
}
conn, ok := mgr.GetServer(name)
if !ok {
return probeResult{}, fmt.Errorf("server %q did not register a connection", name)
}
return probeResult{ToolCount: len(conn.Tools)}, nil
}
func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) {
if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil {
return false, err
}
var answer string
if _, err := fmt.Fscanln(r, &answer); err != nil {
if errors.Is(err, io.EOF) {
return false, nil
}
return false, err
}
answer = strings.TrimSpace(strings.ToLower(answer))
return answer == "y" || answer == "yes", nil
}

View file

@ -0,0 +1,78 @@
package mcp
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
)
func newListCommand() *cobra.Command {
var (
includeStatus bool
timeout time.Duration
)
cmd := &cobra.Command{
Use: "list",
Short: "List configured MCP servers",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
if len(cfg.Tools.MCP.Servers) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.")
return nil
}
rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers))
for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) {
server := cfg.Tools.MCP.Servers[name]
status := "disabled"
if server.Enabled {
status = "enabled"
}
if includeStatus && server.Enabled {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath())
cancel()
if probeErr != nil {
status = "error"
} else {
status = fmt.Sprintf("ok (%d tools)", result.ToolCount)
}
}
effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled
deferredExplicit := server.Deferred != nil
if deferredExplicit {
effectiveDeferred = *server.Deferred
}
rows = append(rows, cliui.MCPListRow{
Name: name,
Type: inferTransportType(server),
Target: renderServerTarget(server),
Status: status,
EffectiveDeferred: effectiveDeferred,
DeferredExplicit: deferredExplicit,
})
}
cliui.PrintMCPList(cmd.OutOrStdout(), rows)
return nil
},
}
cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status")
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check")
return cmd
}

View file

@ -0,0 +1,39 @@
package mcp
import (
"fmt"
"github.com/spf13/cobra"
)
func newRemoveCommand() *cobra.Command {
return &cobra.Command{
Use: "remove <name>",
Short: "Remove an MCP server from config",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
name := args[0]
if _, exists := cfg.Tools.MCP.Servers[name]; !exists {
return fmt.Errorf("MCP server %q not found", name)
}
delete(cfg.Tools.MCP.Servers, name)
if len(cfg.Tools.MCP.Servers) == 0 {
cfg.Tools.MCP.Servers = nil
cfg.Tools.MCP.Enabled = false
}
if err := saveValidatedConfig(cfg); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name)
return nil
},
}
}

View file

@ -0,0 +1,237 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/config"
picomcp "github.com/sipeed/picoclaw/pkg/mcp"
)
type toolDetail struct {
Name string
Description string
Parameters []paramDetail
}
type paramDetail struct {
Name string
Type string
Description string
Required bool
}
var serverShowProbe = defaultServerShowProbe
func defaultServerShowProbe(
ctx context.Context,
name string,
server config.MCPServerConfig,
workspacePath string,
) ([]toolDetail, error) {
mgr := picomcp.NewManager()
defer func() { _ = mgr.Close() }()
server.Enabled = true
mcpCfg := config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Servers: map[string]config.MCPServerConfig{
name: server,
},
}
if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
return nil, err
}
conn, ok := mgr.GetServer(name)
if !ok {
return nil, fmt.Errorf("server %q did not register a connection", name)
}
details := make([]toolDetail, 0, len(conn.Tools))
for _, tool := range conn.Tools {
details = append(details, toolDetail{
Name: tool.Name,
Description: tool.Description,
Parameters: extractParameters(tool.InputSchema),
})
}
return details, nil
}
func extractParameters(schema any) []paramDetail {
schemaMap := normalizeSchema(schema)
properties, ok := schemaMap["properties"].(map[string]any)
if !ok || len(properties) == 0 {
return nil
}
required := make(map[string]struct{})
switch raw := schemaMap["required"].(type) {
case []string:
for _, name := range raw {
required[name] = struct{}{}
}
case []any:
for _, value := range raw {
if name, ok := value.(string); ok {
required[name] = struct{}{}
}
}
}
names := make([]string, 0, len(properties))
for name := range properties {
names = append(names, name)
}
sort.Strings(names)
params := make([]paramDetail, 0, len(names))
for _, name := range names {
param := paramDetail{Name: name}
if propMap, ok := properties[name].(map[string]any); ok {
if typeName, ok := propMap["type"].(string); ok {
param.Type = strings.TrimSpace(typeName)
}
if desc, ok := propMap["description"].(string); ok {
param.Description = strings.TrimSpace(desc)
}
}
_, param.Required = required[name]
params = append(params, param)
}
return params
}
func normalizeSchema(schema any) map[string]any {
if schema == nil {
return map[string]any{}
}
if schemaMap, ok := schema.(map[string]any); ok {
return schemaMap
}
var jsonData []byte
switch raw := schema.(type) {
case json.RawMessage:
jsonData = raw
case []byte:
jsonData = raw
default:
var err error
jsonData, err = json.Marshal(schema)
if err != nil {
return map[string]any{}
}
}
var result map[string]any
if err := json.Unmarshal(jsonData, &result); err != nil {
return map[string]any{}
}
return result
}
func newShowCommand() *cobra.Command {
var timeout time.Duration
cmd := &cobra.Command{
Use: "show <name>",
Short: "Show details and tools for a configured MCP server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
name := args[0]
server, exists := cfg.Tools.MCP.Servers[name]
if !exists {
return fmt.Errorf("MCP server %q not found", name)
}
serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled)
if !server.Enabled {
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath())
if err != nil {
return fmt.Errorf("failed to connect to MCP server %q: %w", name, err)
}
tools := make([]cliui.MCPShowTool, 0, len(details))
for _, d := range details {
params := make([]cliui.MCPShowParam, 0, len(d.Parameters))
for _, p := range d.Parameters {
params = append(params, cliui.MCPShowParam{
Name: p.Name,
Type: p.Type,
Description: p.Description,
Required: p.Required,
})
}
tools = append(tools, cliui.MCPShowTool{
Name: d.Name,
Description: d.Description,
Parameters: params,
})
}
cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false)
return nil
},
}
cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout")
return cmd
}
func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer {
effectiveDeferred := discoveryEnabled
deferredExplicit := server.Deferred != nil
if deferredExplicit {
effectiveDeferred = *server.Deferred
}
info := cliui.MCPShowServer{
Name: name,
Type: inferTransportType(server),
Target: renderServerTarget(server),
Enabled: server.Enabled,
EffectiveDeferred: effectiveDeferred,
DeferredExplicit: deferredExplicit,
EnvFile: server.EnvFile,
}
if len(server.Env) > 0 {
keys := make([]string, 0, len(server.Env))
for k := range server.Env {
keys = append(keys, k)
}
sort.Strings(keys)
info.EnvKeys = keys
}
if len(server.Headers) > 0 {
keys := make([]string, 0, len(server.Headers))
for k := range server.Headers {
keys = append(keys, k)
}
sort.Strings(keys)
info.Headers = keys
}
return info
}

View file

@ -0,0 +1,46 @@
package mcp
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
)
func newTestCommand() *cobra.Command {
var timeout time.Duration
cmd := &cobra.Command{
Use: "test <name>",
Short: "Test connectivity for a configured MCP server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
name := args[0]
server, exists := cfg.Tools.MCP.Servers[name]
if !exists {
return fmt.Errorf("MCP server %q not found", name)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
result, err := serverProbe(ctx, name, server, cfg.WorkspacePath())
if err != nil {
return fmt.Errorf("failed to reach MCP server %q: %w", name, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount)
return nil
},
}
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout")
return cmd
}

View file

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

View file

@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/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(),

View file

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

View file

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

View file

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

View file

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

View file

@ -554,7 +554,20 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet
}
```
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool).
Puoi gestire i casi MCP più comuni direttamente dalla CLI senza modificare a mano il JSON:
```bash
picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
picoclaw mcp list
picoclaw mcp test filesystem
```
`picoclaw mcp` agisce come configuration manager: aggiorna `config.json` sotto `tools.mcp.servers`, ma non mantiene in esecuzione il processo del server.
Usa `picoclaw mcp edit` quando ti servono campi avanzati che non sono coperti da `picoclaw mcp add`.
Per esempio, `picoclaw mcp add` supporta `--deferred` e `--env-file`, mentre `picoclaw mcp edit` resta utile per modifiche JSON dirette e opzioni MCP meno comuni.
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). Per la reference della CLI, vedi [MCP Server CLI](../reference/mcp-cli.md).
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
@ -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 |

View file

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

@ -0,0 +1,361 @@
# MCP Server CLI
> Back to [README](../README.md)
PicoClaw includes an `mcp` CLI command group for managing MCP server entries in `config.json`.
This CLI acts as a **configuration manager**:
- it adds, updates, removes, and validates entries under `tools.mcp.servers`
- it does **not** keep MCP servers running itself
- the gateway / host still starts the configured servers when MCP is enabled
## Where It Writes
The CLI updates the same config file used by the rest of PicoClaw:
- `PICOCLAW_CONFIG` if set
- otherwise `~/.picoclaw/config.json`
When the CLI writes the file, it:
- saves atomically
- preserves the standard 2-space JSON formatting used by PicoClaw
- validates the generated JSON before writing
Behavior notes:
- `picoclaw mcp add ...` enables `tools.mcp.enabled`
- removing the last server with `picoclaw mcp remove ...` disables `tools.mcp.enabled`
## Quick Start
Add a stdio server via `npx`:
```bash
picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp
```
Add a stdio server with environment variables saved in config:
```bash
picoclaw mcp add github --env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx -- npx -y @modelcontextprotocol/server-github
```
Add a stdio server using an env file for secrets:
```bash
picoclaw mcp add github --env-file .env.github -- npx -y @modelcontextprotocol/server-github
```
Add a remote HTTP server:
```bash
picoclaw mcp add context7 --transport http https://mcp.context7.com/mcp
```
Add a remote HTTP server with auth header, even with flags after the URL:
```bash
picoclaw mcp add apify "https://mcp.apify.com/" -t http --header "Authorization: Bearer OMITTED"
```
Add a stdio server using an explicit command separator:
```bash
picoclaw mcp add --transport stdio --env AIRTABLE_API_KEY=YOUR_KEY airtable -- npx -y airtable-mcp-server
```
Inspect the configured entries:
```bash
picoclaw mcp list
picoclaw mcp list --status
```
Inspect one server's full details and its exposed tools:
```bash
picoclaw mcp show filesystem
```
Probe a single server entry:
```bash
picoclaw mcp test filesystem
```
Open the raw config for advanced editing:
```bash
picoclaw mcp edit
```
## Command Summary
| Command | Purpose |
|---------|---------|
| `picoclaw mcp add <name> [flags] <command-or-url> [args...]` | Add or update an MCP server entry |
| `picoclaw mcp remove <name>` | Remove a server entry from config |
| `picoclaw mcp list` | List configured MCP servers |
| `picoclaw mcp show <name>` | Show full details and tools for one server |
| `picoclaw mcp test <name>` | Try connecting to one configured server |
| `picoclaw mcp edit` | Open `config.json` in `$EDITOR` |
## `picoclaw mcp add`
Syntax:
```bash
picoclaw mcp add <name> [flags] <command-or-url> [args...]
```
Supported flags:
| Flag | Meaning |
|------|---------|
| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. Values are saved to config. |
| `--env-file` | Attach an env file path to a stdio server. Recommended for secrets you do not want stored inline in `config.json`. |
| `--header`, `-H` | Add an HTTP header in `Name: Value` or `Name=Value` format. Repeatable. |
| `--transport`, `-t` | Transport type: `stdio` (default), `http`, or `sse`. |
| `--force`, `-f` | Overwrite an existing server entry without confirmation. |
| `--deferred` | Mark the server as deferred: tools are hidden and discoverable on demand. |
| `--no-deferred` | Mark the server as non-deferred: tools are always loaded into context. |
When neither `--deferred` nor `--no-deferred` is passed, the `deferred` field is omitted from the stored config and the global `discovery.enabled` value applies at runtime.
Supported forms:
```bash
picoclaw mcp add [flags] <name> <command-or-url> [args...]
picoclaw mcp add [flags] <name> -- <command> [args...]
```
Parsing behavior:
- CLI flags can appear before the name, between the name and target, or after the URL for remote transports
- for `stdio`, the most robust form is `-- <command> [args...]`
- use the `--` separator when the stdio command itself has arguments that may look like PicoClaw CLI flags
- without `--`, PicoClaw treats the first two non-flag tokens as `<name>` and `<command-or-url>`
Secret handling:
- `--env KEY=value` stores the resolved value directly in `config.json`
- use `--env-file` instead when the value is sensitive and should stay outside the main config file
Example:
```bash
picoclaw mcp add sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db
```
This stores:
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"sqlite": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"]
}
}
}
}
}
```
Adding the same server with `--deferred` stores the extra field:
```bash
picoclaw mcp add --deferred sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db
```
```json
{
"sqlite": {
"enabled": true,
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"],
"deferred": true
}
}
```
### Add Command Rules
For `stdio`:
- `<command-or-url>` is treated as the command
- `[args...]` are stored in `args`
- `--env` is supported
- `--env-file` is supported and stored in `env_file`
- `--header` is rejected
- `-- <command> [args...]` is supported and recommended for unambiguous parsing
For `http` / `sse`:
- `<command-or-url>` must be a valid URL
- extra command args are rejected
- `--env` is rejected
- `--env-file` is rejected
- `--header` is supported and stored in `headers`
Overwrite behavior:
- if `<name>` already exists, PicoClaw asks for confirmation
- use `--force` to skip the prompt
Local path validation:
- if the command looks like a local path such as `./server.py` or `/opt/mcp/server`
- PicoClaw checks that the file exists
- on non-Windows platforms, it also checks that the file is executable
Clear URL/transport error:
- if the target looks like `https://...` but transport is still `stdio`, PicoClaw returns an explicit error telling you to use `--transport http` or `--transport sse`
## `picoclaw mcp remove`
Syntax:
```bash
picoclaw mcp remove <name>
```
This removes the named entry from `tools.mcp.servers`.
If the removed server was the last configured MCP server, PicoClaw also disables `tools.mcp.enabled`.
## `picoclaw mcp list`
Syntax:
```bash
picoclaw mcp list
picoclaw mcp list --status
```
On wide terminals the output is a styled box (same look as `mcp show`). On narrow terminals or when stdout is not a TTY, a plain ASCII table is printed instead.
Output fields:
| Field | Meaning |
|-------|---------|
| `Name` | Server key inside `tools.mcp.servers` |
| `Type` | Effective transport: `stdio`, `http`, or `sse` |
| `Command` / `Target` | Stored command line for stdio servers, or URL for remote servers |
| `Status` | `enabled` / `disabled` by default; with `--status`: `ok (N tools)` or `error` |
| `Deferred` | `deferred` if the per-server override is `true`; `eager` if `false`; omitted if not set |
Notes:
- without `--status`, PicoClaw prints configuration state only
- with `--status`, PicoClaw tries to connect to each enabled server and reports `ok (N tools)` or `error`
- to see the full list of tools a server exposes, use `picoclaw mcp show <name>`
## `picoclaw mcp show`
Syntax:
```bash
picoclaw mcp show <name>
picoclaw mcp show <name> --timeout 15s
```
This connects to the named server and prints:
- server metadata: name, transport type, target, enabled state, deferred override, env var names, env file, header names
- every tool the server exposes, with its name, description, and parameters (name, type, required/optional, description)
On wide terminals the output is a styled box matching the `mcp list` look. On narrow terminals or non-TTY stdout, plain text is printed instead.
Example output (wide terminal):
```
╭──────────────────────────────────────────────────────────╮
│ ⬡ filesystem │
│ │
│ Type stdio │
│ Target npx -y @modelcontextprotocol/server-fs /tmp │
│ Enabled yes │
│ Deferred no │
│ │
│ Tools (3) │
│ │
│ read_file [1/3] │
│ Read the complete contents of a file from the disk │
│ │
│ path <string> required │
│ Path to the file to read │
│ ──────────────────────────────────────────────────────── │
│ ... │
╰──────────────────────────────────────────────────────────╯
```
Flags:
| Flag | Default | Meaning |
|------|---------|---------|
| `--timeout` | `10s` | Connection timeout |
Notes:
- if the server is disabled in config, `mcp show` prints the metadata only and skips tool discovery
- `mcp show` always connects live to fetch the tool list; use `mcp test` if you only need a reachability check
## `picoclaw mcp test`
Syntax:
```bash
picoclaw mcp test <name>
```
This performs a direct connection test for one configured entry and prints the number of discovered tools when successful.
It is useful when:
- you want to verify a newly added server before starting the gateway
- you want to debug one server without probing the whole list
- the entry is currently disabled in config but you still want to validate its definition
## `picoclaw mcp edit`
Syntax:
```bash
picoclaw mcp edit
```
This opens the config file in the editor pointed to by `$EDITOR`.
Use it when you need to configure MCP fields that are not exposed directly by `picoclaw mcp add`.
If `$EDITOR` is not set, the command fails with an explicit error.
## Recommended Workflow
For common cases:
1. Add the server with `picoclaw mcp add` (include `--deferred` if you want tools hidden by default).
2. Verify connectivity and inspect the exposed tools with `picoclaw mcp show <name>`.
3. Check all servers at a glance with `picoclaw mcp list --status`.
4. Start PicoClaw normally so the configured MCP server is loaded by the host.
For advanced cases:
1. Add the base entry with `picoclaw mcp add`.
2. Run `picoclaw mcp edit` to fill in fields that are not exposed as CLI flags.
3. Run `picoclaw mcp show <name>` to confirm the final configuration and tool list.
## Related Docs
- [Tools Configuration](tools_configuration.md#mcp-tool): MCP config structure, transports, discovery, and examples
- [README](../README.md): high-level overview

View file

@ -258,6 +258,17 @@ For schedule types, execution modes (`deliver`, agent turn, and command jobs), p
The MCP tool enables integration with external Model Context Protocol servers.
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
View file

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

@ -23,38 +23,38 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/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=

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,496 @@
package agent
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
type PromptLayer string
const (
PromptLayerKernel PromptLayer = "kernel"
PromptLayerInstruction PromptLayer = "instruction"
PromptLayerCapability PromptLayer = "capability"
PromptLayerContext PromptLayer = "context"
PromptLayerTurn PromptLayer = "turn"
)
type PromptSlot string
const (
PromptSlotIdentity PromptSlot = "identity"
PromptSlotHierarchy PromptSlot = "hierarchy"
PromptSlotWorkspace PromptSlot = "workspace"
PromptSlotTooling PromptSlot = "tooling"
PromptSlotMCP PromptSlot = "mcp"
PromptSlotSkillCatalog PromptSlot = "skill_catalog"
PromptSlotActiveSkill PromptSlot = "active_skill"
PromptSlotMemory PromptSlot = "memory"
PromptSlotRuntime PromptSlot = "runtime"
PromptSlotSummary PromptSlot = "summary"
PromptSlotMessage PromptSlot = "message"
PromptSlotSteering PromptSlot = "steering"
PromptSlotSubTurn PromptSlot = "subturn"
PromptSlotInterrupt PromptSlot = "interrupt"
PromptSlotOutput PromptSlot = "output"
)
type PromptSourceID string
const (
PromptSourceKernel PromptSourceID = "runtime.kernel"
PromptSourceHierarchy PromptSourceID = "runtime.hierarchy"
PromptSourceWorkspace PromptSourceID = "workspace.definition"
PromptSourceRuntime PromptSourceID = "runtime.context"
PromptSourceSummary PromptSourceID = "context.summary"
PromptSourceMemory PromptSourceID = "memory:workspace"
PromptSourceSkillCatalog PromptSourceID = "skill:index"
PromptSourceActiveSkills PromptSourceID = "skill:active"
PromptSourceToolRegistry PromptSourceID = "tool_registry:native"
PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery"
PromptSourceOutputPolicy PromptSourceID = "runtime.output"
PromptSourceSubTurnProfile PromptSourceID = "subturn.profile"
PromptSourceUserMessage PromptSourceID = "turn:user_message"
PromptSourceSteering PromptSourceID = "turn:steering"
PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result"
PromptSourceInterrupt PromptSourceID = "turn:interrupt"
)
type PromptCachePolicy string
const (
PromptCacheDefault PromptCachePolicy = ""
PromptCacheEphemeral PromptCachePolicy = "ephemeral"
PromptCacheNone PromptCachePolicy = "none"
)
type PromptPlacement struct {
Layer PromptLayer
Slot PromptSlot
}
type PromptSourceDescriptor struct {
ID PromptSourceID
Owner string
Description string
Allowed []PromptPlacement
StableByDefault bool
}
type PromptSource struct {
ID PromptSourceID
Name string
Path string
}
type PromptPart struct {
ID string
Layer PromptLayer
Slot PromptSlot
Source PromptSource
Title string
Content string
Stable bool
Cache PromptCachePolicy
}
type PromptBuildRequest struct {
History []providers.Message
Summary string
CurrentMessage string
Media []string
Channel string
ChatID string
SenderID string
SenderDisplayName string
ActiveSkills []string
Overlays []PromptPart
}
type PromptContributor interface {
PromptSource() PromptSourceDescriptor
ContributePrompt(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error)
}
type PromptRegistry struct {
mu sync.RWMutex
sources map[PromptSourceID]PromptSourceDescriptor
contributors []PromptContributor
warned map[PromptSourceID]struct{}
}
func NewPromptRegistry() *PromptRegistry {
r := &PromptRegistry{
sources: make(map[PromptSourceID]PromptSourceDescriptor),
warned: make(map[PromptSourceID]struct{}),
}
for _, desc := range builtinPromptSources() {
if err := r.RegisterSource(desc); err != nil {
logger.WarnCF("agent", "Failed to register builtin prompt source", map[string]any{
"source": desc.ID,
"error": err.Error(),
})
}
}
return r
}
func builtinPromptSources() []PromptSourceDescriptor {
return []PromptSourceDescriptor{
{
ID: PromptSourceKernel,
Owner: "agent",
Description: "Core picoclaw identity and hard rules",
Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotIdentity}},
StableByDefault: true,
},
{
ID: PromptSourceHierarchy,
Owner: "agent",
Description: "Prompt hierarchy rules",
Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotHierarchy}},
StableByDefault: true,
},
{
ID: PromptSourceWorkspace,
Owner: "workspace",
Description: "Workspace and agent definition files",
Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}},
StableByDefault: true,
},
{
ID: PromptSourceToolDiscovery,
Owner: "tools",
Description: "Tool discovery instructions",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
StableByDefault: true,
},
{
ID: PromptSourceToolRegistry,
Owner: "tools",
Description: "Native provider tool definitions",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
StableByDefault: true,
},
{
ID: PromptSourceSkillCatalog,
Owner: "skills",
Description: "Installed skill catalog",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotSkillCatalog}},
StableByDefault: true,
},
{
ID: PromptSourceActiveSkills,
Owner: "skills",
Description: "Active skill instructions for the current request",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}},
StableByDefault: false,
},
{
ID: PromptSourceMemory,
Owner: "memory",
Description: "Workspace memory context",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotMemory}},
StableByDefault: true,
},
{
ID: PromptSourceRuntime,
Owner: "agent",
Description: "Per-request runtime context",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotRuntime}},
StableByDefault: false,
},
{
ID: PromptSourceSummary,
Owner: "context_manager",
Description: "Conversation summary context",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotSummary}},
StableByDefault: false,
},
{
ID: PromptSourceOutputPolicy,
Owner: "agent",
Description: "Output formatting policy",
Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotOutput}},
StableByDefault: true,
},
{
ID: PromptSourceSubTurnProfile,
Owner: "subturn",
Description: "Child agent profile instructions",
Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}},
StableByDefault: false,
},
{
ID: PromptSourceUserMessage,
Owner: "turn",
Description: "Current user message for this turn",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotMessage}},
StableByDefault: false,
},
{
ID: PromptSourceSteering,
Owner: "turn",
Description: "Steering message injected into a running turn",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSteering}},
StableByDefault: false,
},
{
ID: PromptSourceSubTurnResult,
Owner: "turn",
Description: "SubTurn result injected into a parent turn",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSubTurn}},
StableByDefault: false,
},
{
ID: PromptSourceInterrupt,
Owner: "turn",
Description: "Graceful interrupt hint injected into the terminal LLM call",
Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotInterrupt}},
StableByDefault: false,
},
}
}
func (r *PromptRegistry) RegisterSource(desc PromptSourceDescriptor) error {
if r == nil {
return fmt.Errorf("prompt registry is nil")
}
desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID)))
if desc.ID == "" {
return fmt.Errorf("prompt source id is required")
}
if len(desc.Allowed) == 0 {
return fmt.Errorf("prompt source %q must declare at least one placement", desc.ID)
}
r.mu.Lock()
defer r.mu.Unlock()
r.sources[desc.ID] = clonePromptSourceDescriptor(desc)
return nil
}
func (r *PromptRegistry) RegisterContributor(contributor PromptContributor) error {
if r == nil {
return fmt.Errorf("prompt registry is nil")
}
if contributor == nil {
return fmt.Errorf("prompt contributor is nil")
}
desc := contributor.PromptSource()
desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID)))
if err := r.RegisterSource(desc); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
r.contributors = slices.DeleteFunc(r.contributors, func(existing PromptContributor) bool {
return PromptSourceID(strings.TrimSpace(string(existing.PromptSource().ID))) == desc.ID
})
r.contributors = append(r.contributors, contributor)
return nil
}
func (r *PromptRegistry) Collect(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) {
if r == nil {
return nil, nil
}
r.mu.RLock()
contributors := append([]PromptContributor(nil), r.contributors...)
r.mu.RUnlock()
var parts []PromptPart
for _, contributor := range contributors {
contributed, err := contributor.ContributePrompt(ctx, req)
if err != nil {
return nil, err
}
for _, part := range contributed {
if err := r.ValidatePart(part); err != nil {
return nil, err
}
parts = append(parts, part)
}
}
return parts, nil
}
func (r *PromptRegistry) ValidatePart(part PromptPart) error {
if r == nil {
return nil
}
sourceID := PromptSourceID(strings.TrimSpace(string(part.Source.ID)))
if sourceID == "" {
return fmt.Errorf("prompt part %q has empty source id", part.ID)
}
r.mu.Lock()
defer r.mu.Unlock()
desc, ok := r.sources[sourceID]
if !ok {
if _, warned := r.warned[sourceID]; !warned {
r.warned[sourceID] = struct{}{}
logger.WarnCF("agent", "Unregistered prompt source allowed in compatibility mode", map[string]any{
"source": sourceID,
"layer": part.Layer,
"slot": part.Slot,
"part": part.ID,
})
}
return nil
}
if promptPlacementAllowed(desc.Allowed, PromptPlacement{Layer: part.Layer, Slot: part.Slot}) {
return nil
}
return fmt.Errorf("prompt source %q cannot write to %s/%s", sourceID, part.Layer, part.Slot)
}
func promptPlacementAllowed(allowed []PromptPlacement, placement PromptPlacement) bool {
return slices.ContainsFunc(allowed, func(candidate PromptPlacement) bool {
return candidate.Layer == placement.Layer && candidate.Slot == placement.Slot
})
}
func clonePromptSourceDescriptor(desc PromptSourceDescriptor) PromptSourceDescriptor {
desc.Allowed = append([]PromptPlacement(nil), desc.Allowed...)
return desc
}
type PromptStack struct {
registry *PromptRegistry
parts []PromptPart
sealed bool
}
func NewPromptStack(registry *PromptRegistry) *PromptStack {
return &PromptStack{registry: registry}
}
func (s *PromptStack) Add(part PromptPart) error {
if s == nil {
return fmt.Errorf("prompt stack is nil")
}
if s.sealed {
return fmt.Errorf("prompt stack is sealed")
}
if strings.TrimSpace(part.Content) == "" {
return nil
}
if strings.TrimSpace(part.ID) == "" {
return fmt.Errorf("prompt part id is required")
}
if s.registry != nil {
if err := s.registry.ValidatePart(part); err != nil {
return err
}
}
s.parts = append(s.parts, part)
return nil
}
func (s *PromptStack) Seal() {
if s != nil {
s.sealed = true
}
}
func (s *PromptStack) Parts() []PromptPart {
if s == nil || len(s.parts) == 0 {
return nil
}
return append([]PromptPart(nil), s.parts...)
}
func renderPromptPartsLegacy(parts []PromptPart) string {
textParts := make([]string, 0, len(parts))
for _, part := range sortPromptParts(parts) {
if strings.TrimSpace(part.Content) == "" {
continue
}
textParts = append(textParts, part.Content)
}
return strings.Join(textParts, "\n\n---\n\n")
}
func sortPromptParts(parts []PromptPart) []PromptPart {
sorted := append([]PromptPart(nil), parts...)
slices.SortStableFunc(sorted, func(a, b PromptPart) int {
if d := layerPriority(b.Layer) - layerPriority(a.Layer); d != 0 {
return d
}
if d := slotPriority(b.Slot) - slotPriority(a.Slot); d != 0 {
return d
}
if a.Source.ID != b.Source.ID {
return strings.Compare(string(a.Source.ID), string(b.Source.ID))
}
return strings.Compare(a.ID, b.ID)
})
return sorted
}
func layerPriority(layer PromptLayer) int {
switch layer {
case PromptLayerKernel:
return 100
case PromptLayerInstruction:
return 80
case PromptLayerCapability:
return 60
case PromptLayerContext:
return 40
case PromptLayerTurn:
return 20
default:
return 0
}
}
func slotPriority(slot PromptSlot) int {
switch slot {
case PromptSlotIdentity:
return 1000
case PromptSlotHierarchy:
return 990
case PromptSlotWorkspace:
return 900
case PromptSlotTooling:
return 800
case PromptSlotMCP:
return 790
case PromptSlotSkillCatalog:
return 780
case PromptSlotActiveSkill:
return 770
case PromptSlotMemory:
return 700
case PromptSlotOutput:
return 695
case PromptSlotRuntime:
return 690
case PromptSlotSummary:
return 680
case PromptSlotMessage:
return 600
case PromptSlotSteering:
return 590
case PromptSlotSubTurn:
return 580
case PromptSlotInterrupt:
return 570
default:
return 0
}
}

View file

@ -0,0 +1,139 @@
package agent
import (
"context"
"fmt"
"strings"
)
type toolDiscoveryPromptContributor struct {
useBM25 bool
useRegex bool
}
func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor {
return PromptSourceDescriptor{
ID: PromptSourceToolDiscovery,
Owner: "tools",
Description: "Tool discovery instructions",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
StableByDefault: true,
}
}
func (c toolDiscoveryPromptContributor) ContributePrompt(
_ context.Context,
_ PromptBuildRequest,
) ([]PromptPart, error) {
content := formatToolDiscoveryRule(c.useBM25, c.useRegex)
if strings.TrimSpace(content) == "" {
return nil, nil
}
return []PromptPart{
{
ID: "capability.tool_discovery",
Layer: PromptLayerCapability,
Slot: PromptSlotTooling,
Source: PromptSource{ID: PromptSourceToolDiscovery, Name: "tool_registry:discovery"},
Title: "tool discovery",
Content: content,
Stable: true,
Cache: PromptCacheEphemeral,
},
}, nil
}
type mcpServerPromptContributor struct {
serverName string
toolCount int
deferred bool
}
func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor {
return PromptSourceDescriptor{
ID: mcpPromptSourceID(c.serverName),
Owner: "mcp",
Description: fmt.Sprintf("MCP server %q capability prompt", c.serverName),
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}},
StableByDefault: true,
}
}
func (c mcpServerPromptContributor) ContributePrompt(
_ context.Context,
_ PromptBuildRequest,
) ([]PromptPart, error) {
serverName := strings.TrimSpace(c.serverName)
if serverName == "" || c.toolCount <= 0 {
return nil, nil
}
availability := "available as native tools"
if c.deferred {
availability = "hidden behind tool discovery until unlocked"
}
return []PromptPart{
{
ID: "capability.mcp." + promptSourceComponent(serverName),
Layer: PromptLayerCapability,
Slot: PromptSlotMCP,
Source: PromptSource{ID: mcpPromptSourceID(serverName), Name: "mcp:" + serverName},
Title: "MCP server capability",
Content: fmt.Sprintf(
"MCP server `%s` is connected. It contributes %d tool(s), currently %s.",
serverName,
c.toolCount,
availability,
),
Stable: true,
Cache: PromptCacheEphemeral,
},
}, nil
}
func mcpPromptSourceID(serverName string) PromptSourceID {
return PromptSourceID("mcp:" + promptSourceComponent(serverName))
}
func promptSourceComponent(value string) string {
const maxLen = 64
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return "unnamed"
}
var b strings.Builder
lastWasSep := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
lastWasSep = false
case r >= '0' && r <= '9':
b.WriteRune(r)
lastWasSep = false
case r == '-' || r == '_':
if !lastWasSep && b.Len() > 0 {
b.WriteRune(r)
lastWasSep = true
}
default:
if !lastWasSep && b.Len() > 0 {
b.WriteRune('_')
lastWasSep = true
}
}
}
result := strings.Trim(b.String(), "_")
if result == "" {
return "unnamed"
}
if len(result) > maxLen {
return result[:maxLen]
}
return result
}

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

@ -0,0 +1,275 @@
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
)
func TestPromptRegistry_RejectsRegisteredSourceWrongPlacement(t *testing.T) {
registry := NewPromptRegistry()
if err := registry.RegisterSource(PromptSourceDescriptor{
ID: "test:source",
Owner: "test",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
}); err != nil {
t.Fatalf("RegisterSource() error = %v", err)
}
err := registry.ValidatePart(PromptPart{
ID: "wrong.placement",
Layer: PromptLayerContext,
Slot: PromptSlotRuntime,
Source: PromptSource{ID: "test:source"},
Content: "runtime text",
})
if err == nil {
t.Fatal("ValidatePart() error = nil, want placement error")
}
}
func TestPromptRegistry_AllowsUnregisteredSourceInCompatibilityMode(t *testing.T) {
registry := NewPromptRegistry()
err := registry.ValidatePart(PromptPart{
ID: "unregistered.part",
Layer: PromptLayerCapability,
Slot: PromptSlotMCP,
Source: PromptSource{ID: "mcp:dynamic-server"},
Content: "dynamic MCP prompt",
})
if err != nil {
t.Fatalf("ValidatePart() error = %v, want nil for unregistered source", err)
}
}
func TestRenderPromptPartsLegacy_UsesLayerAndSlotOrder(t *testing.T) {
parts := []PromptPart{
{
ID: "context.runtime",
Layer: PromptLayerContext,
Slot: PromptSlotRuntime,
Source: PromptSource{ID: PromptSourceRuntime},
Content: "runtime",
},
{
ID: "kernel.identity",
Layer: PromptLayerKernel,
Slot: PromptSlotIdentity,
Source: PromptSource{ID: PromptSourceKernel},
Content: "kernel",
},
{
ID: "capability.skill",
Layer: PromptLayerCapability,
Slot: PromptSlotActiveSkill,
Source: PromptSource{ID: "skill:test"},
Content: "skill",
},
{
ID: "instruction.workspace",
Layer: PromptLayerInstruction,
Slot: PromptSlotWorkspace,
Source: PromptSource{ID: PromptSourceWorkspace},
Content: "workspace",
},
}
got := renderPromptPartsLegacy(parts)
want := strings.Join([]string{"kernel", "workspace", "skill", "runtime"}, "\n\n---\n\n")
if got != want {
t.Fatalf("renderPromptPartsLegacy() = %q, want %q", got, want)
}
}
func TestBuildMessagesFromPrompt_IncludesSystemPromptOverlay(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir())
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
CurrentMessage: "do child task",
Overlays: promptOverlaysForOptions(processOptions{
SystemPromptOverride: "Use child-only system instructions.",
}),
})
if len(messages) < 2 {
t.Fatalf("messages len = %d, want at least 2", len(messages))
}
if messages[0].Role != "system" {
t.Fatalf("messages[0].Role = %q, want system", messages[0].Role)
}
if !strings.Contains(messages[0].Content, "Use child-only system instructions.") {
t.Fatalf("system prompt missing overlay: %q", messages[0].Content)
}
if messages[1].Role != "user" || messages[1].Content != "do child task" {
t.Fatalf("messages[1] = %#v, want user task", messages[1])
}
}
func TestBuildMessagesFromPrompt_AttachesInternalPromptMetadata(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir())
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{
CurrentMessage: "hello",
Summary: "prior context",
})
if len(messages) != 2 {
t.Fatalf("messages len = %d, want 2", len(messages))
}
system := messages[0]
if len(system.SystemParts) < 3 {
t.Fatalf("system parts len = %d, want at least 3", len(system.SystemParts))
}
if system.SystemParts[0].PromptLayer != string(PromptLayerKernel) ||
system.SystemParts[0].PromptSlot != string(PromptSlotIdentity) ||
system.SystemParts[0].PromptSource != string(PromptSourceKernel) {
t.Fatalf("static system metadata = %#v, want kernel identity", system.SystemParts[0])
}
var hasRuntime, hasSummary bool
for _, part := range system.SystemParts {
switch part.PromptSource {
case string(PromptSourceRuntime):
hasRuntime = true
if part.CacheControl != nil {
t.Fatalf("runtime cache control = %#v, want nil", part.CacheControl)
}
case string(PromptSourceSummary):
hasSummary = true
if part.CacheControl != nil {
t.Fatalf("summary cache control = %#v, want nil", part.CacheControl)
}
}
}
if !hasRuntime {
t.Fatal("system parts missing runtime prompt metadata")
}
if !hasSummary {
t.Fatal("system parts missing summary prompt metadata")
}
user := messages[1]
if user.PromptLayer != string(PromptLayerTurn) ||
user.PromptSlot != string(PromptSlotMessage) ||
user.PromptSource != string(PromptSourceUserMessage) {
t.Fatalf("user message metadata = %#v, want turn message", user)
}
data, err := json.Marshal(messages)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if strings.Contains(string(data), "PromptSource") ||
strings.Contains(string(data), "PromptLayer") ||
strings.Contains(string(data), "PromptSlot") {
t.Fatalf("internal prompt metadata leaked into JSON: %s", data)
}
}
func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false)
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
system := messages[0]
if !strings.Contains(system.Content, "tool_search_tool_bm25") {
t.Fatalf("system prompt missing tool discovery rule: %q", system.Content)
}
var found bool
for _, part := range system.SystemParts {
if part.PromptSource == string(PromptSourceToolDiscovery) {
found = true
if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotTooling) {
t.Fatalf("tool discovery metadata = %#v, want capability/tooling", part)
}
if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" {
t.Fatalf("tool discovery cache control = %#v, want ephemeral", part.CacheControl)
}
}
}
if !found {
t.Fatal("system parts missing tool discovery prompt metadata")
}
}
func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir())
err := cb.RegisterPromptContributor(mcpServerPromptContributor{
serverName: "GitHub Server",
toolCount: 3,
deferred: true,
})
if err != nil {
t.Fatalf("RegisterPromptContributor() error = %v", err)
}
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
system := messages[0]
if !strings.Contains(system.Content, "MCP server `GitHub Server` is connected") {
t.Fatalf("system prompt missing MCP contributor content: %q", system.Content)
}
var found bool
for _, part := range system.SystemParts {
if part.PromptSource == "mcp:github_server" {
found = true
if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotMCP) {
t.Fatalf("mcp metadata = %#v, want capability/mcp", part)
}
if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" {
t.Fatalf("mcp cache control = %#v, want ephemeral", part.CacheControl)
}
}
}
if !found {
t.Fatal("system parts missing MCP prompt metadata")
}
}
type testPromptContributor struct {
desc PromptSourceDescriptor
part PromptPart
}
func (c testPromptContributor) PromptSource() PromptSourceDescriptor {
return c.desc
}
func (c testPromptContributor) ContributePrompt(_ context.Context, _ PromptBuildRequest) ([]PromptPart, error) {
return []PromptPart{c.part}, nil
}
func TestContextBuilder_CollectsRegisteredPromptContributors(t *testing.T) {
t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir())
cb := NewContextBuilder(t.TempDir())
sourceID := PromptSourceID("test:contributor")
err := cb.RegisterPromptContributor(testPromptContributor{
desc: PromptSourceDescriptor{
ID: sourceID,
Owner: "test",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}},
},
part: PromptPart{
ID: "capability.mcp.test",
Layer: PromptLayerCapability,
Slot: PromptSlotMCP,
Source: PromptSource{ID: sourceID, Name: "test"},
Content: "registered contributor prompt",
},
})
if err != nil {
t.Fatalf("RegisterPromptContributor() error = %v", err)
}
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
if !strings.Contains(messages[0].Content, "registered contributor prompt") {
t.Fatalf("system prompt missing contributor content: %q", messages[0].Content)
}
}

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

@ -0,0 +1,129 @@
package agent
import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
)
func promptBuildRequestForTurn(
ts *turnState,
history []providers.Message,
summary string,
currentMessage string,
media []string,
) PromptBuildRequest {
return PromptBuildRequest{
History: history,
Summary: summary,
CurrentMessage: currentMessage,
Media: append([]string(nil), media...),
Channel: ts.channel,
ChatID: ts.chatID,
SenderID: ts.opts.Dispatch.SenderID(),
SenderDisplayName: ts.opts.SenderDisplayName,
ActiveSkills: activeSkillNames(ts.agent, ts.opts),
Overlays: promptOverlaysForOptions(ts.opts),
}
}
func promptOverlaysForOptions(opts processOptions) []PromptPart {
systemPrompt := strings.TrimSpace(opts.SystemPromptOverride)
if systemPrompt == "" {
return nil
}
return []PromptPart{
{
ID: "instruction.subturn_profile",
Layer: PromptLayerInstruction,
Slot: PromptSlotWorkspace,
Source: PromptSource{ID: PromptSourceSubTurnProfile, Name: "subturn.profile"},
Title: "SubTurn System Instructions",
Content: systemPrompt,
Stable: false,
Cache: PromptCacheNone,
},
}
}
func promptContentBlock(part PromptPart, cache *providers.CacheControl) providers.ContentBlock {
if cache == nil {
cache = cacheControlForPromptPart(part)
}
return providers.ContentBlock{
Type: "text",
Text: part.Content,
CacheControl: cache,
PromptLayer: string(part.Layer),
PromptSlot: string(part.Slot),
PromptSource: string(part.Source.ID),
}
}
func cacheControlForPromptPart(part PromptPart) *providers.CacheControl {
switch part.Cache {
case PromptCacheEphemeral:
return &providers.CacheControl{Type: "ephemeral"}
default:
return nil
}
}
func promptMessageWithMetadata(
msg providers.Message,
layer PromptLayer,
slot PromptSlot,
source PromptSourceID,
) providers.Message {
msg.PromptLayer = string(layer)
msg.PromptSlot = string(slot)
msg.PromptSource = string(source)
return msg
}
func promptMessageWithDefaultMetadata(
msg providers.Message,
layer PromptLayer,
slot PromptSlot,
source PromptSourceID,
) providers.Message {
if strings.TrimSpace(msg.PromptSource) != "" {
return msg
}
return promptMessageWithMetadata(msg, layer, slot, source)
}
func userPromptMessage(content string, media []string) providers.Message {
msg := providers.Message{
Role: "user",
Content: content,
}
if len(media) > 0 {
msg.Media = append([]string(nil), media...)
}
return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage)
}
func steeringPromptMessage(msg providers.Message) providers.Message {
return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering)
}
func subTurnResultPromptMessage(content string) providers.Message {
return promptMessageWithMetadata(
providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)},
PromptLayerTurn,
PromptSlotSubTurn,
PromptSourceSubTurnResult,
)
}
func interruptPromptMessage(content string) providers.Message {
return promptMessageWithMetadata(
providers.Message{Role: "user", Content: content},
PromptLayerTurn,
PromptSlotInterrupt,
PromptSourceInterrupt,
)
}

View file

@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers
return fmt.Errorf("steering queue is not initialized")
}
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(),

View file

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

View file

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

View file

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

View file

@ -170,6 +170,20 @@ func dismissTrackedToolFeedbackMessage(
}
}
func clearTrackedToolFeedbackMessage(
ch Channel,
chatID string,
outboundCtx *bus.InboundContext,
) {
trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx)
if trackedChatID == "" {
return
}
if tracker, ok := ch.(toolFeedbackMessageTracker); ok {
tracker.ClearToolFeedbackMessage(trackedChatID)
}
}
func prepareToolFeedbackMessageContent(ch Channel, content string) string {
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

View file

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

View file

@ -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, ">", "&gt;")
return text
}
func escapeHTMLAttr(text string) string {
return html.EscapeString(text)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -278,6 +278,46 @@ func TestRemovePidFileIfPIDMismatch(t *testing.T) {
}
}
// TestWritePidFileContainerPID1 verifies that a leftover PID file with PID 1
// (typical container entrypoint) is treated as stale and overwritten.
func TestWritePidFileContainerPID1(t *testing.T) {
dir := tmpDir(t)
stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"}
raw, _ := json.MarshalIndent(stale, "", " ")
os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600)
data, err := WritePidFile(dir, "127.0.0.1", 18790)
if err != nil {
t.Fatalf("WritePidFile should treat PID 1 as stale, got error: %v", err)
}
if data.PID != os.Getpid() {
t.Errorf("PID = %d, want %d", data.PID, os.Getpid())
}
}
// TestReadPidFileWithCheckContainerPID1 verifies that a leftover PID file
// with PID 1 is treated as stale and cleaned up.
func TestReadPidFileWithCheckContainerPID1(t *testing.T) {
if os.Getpid() == 1 {
t.Skip("test not meaningful when running as PID 1")
}
dir := tmpDir(t)
stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"}
raw, _ := json.MarshalIndent(stale, "", " ")
os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600)
data := ReadPidFileWithCheck(dir)
if data != nil {
t.Error("expected nil for PID 1 leftover")
}
if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) {
t.Error("PID 1 leftover file should be removed")
}
}
// TestReadPidFileUnlockedInvalidJSON returns error for malformed content.
func TestReadPidFileUnlockedInvalidJSON(t *testing.T) {
dir := tmpDir(t)

View file

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

View file

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

View file

@ -0,0 +1,38 @@
package messageutil
import (
"strings"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
// IsTransientAssistantThoughtMessage reports whether msg is an invalid
// reasoning-only assistant history record. These "hanging" thought messages
// are not a canonical persisted format and should be discarded instead of
// replayed or reconstructed.
func IsTransientAssistantThoughtMessage(msg protocoltypes.Message) bool {
return msg.Role == "assistant" &&
strings.TrimSpace(msg.Content) == "" &&
strings.TrimSpace(msg.ReasoningContent) != "" &&
len(msg.ToolCalls) == 0 &&
len(msg.Media) == 0 &&
len(msg.Attachments) == 0 &&
strings.TrimSpace(msg.ToolCallID) == ""
}
// FilterInvalidHistoryMessages removes invalid persisted history records such
// as transient assistant thought-only messages.
func FilterInvalidHistoryMessages(history []protocoltypes.Message) []protocoltypes.Message {
if len(history) == 0 {
return []protocoltypes.Message{}
}
filtered := make([]protocoltypes.Message, 0, len(history))
for _, msg := range history {
if IsTransientAssistantThoughtMessage(msg) {
continue
}
filtered = append(filtered, msg)
}
return filtered
}

View file

@ -15,6 +15,7 @@ import (
"time"
"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,

View file

@ -202,7 +202,7 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) {
}
}
func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) {
func TestProviderChat_StripsReasoningContentForNonDeepSeekHistory(t *testing.T) {
var requestBody map[string]any
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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string {
return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools."
}
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",

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,186 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintf(os.Stderr, "usage: go run scripts/copydir.go <src> <dst>\n")
os.Exit(2)
}
repoRoot, err := findRepoRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "locate repo root: %v\n", err)
os.Exit(1)
}
src, err := normalizePathArg(os.Args[1], repoRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "resolve src path: %v\n", err)
os.Exit(1)
}
dst, err := normalizePathArg(os.Args[2], repoRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "resolve dst path: %v\n", err)
os.Exit(1)
}
if err := ensurePathWithinRepo(repoRoot, src); err != nil {
fmt.Fprintf(os.Stderr, "invalid src path: %v\n", err)
os.Exit(1)
}
if err := ensurePathWithinRepo(repoRoot, dst); err != nil {
fmt.Fprintf(os.Stderr, "invalid dst path: %v\n", err)
os.Exit(1)
}
if samePath(repoRoot, dst) {
fmt.Fprintln(os.Stderr, "invalid dst path: destination cannot be repo root")
os.Exit(1)
}
if err := os.RemoveAll(dst); err != nil {
fmt.Fprintf(os.Stderr, "remove %s: %v\n", dst, err)
os.Exit(1)
}
if err := copyTree(src, dst); err != nil {
fmt.Fprintf(os.Stderr, "copy %s -> %s: %v\n", src, dst, err)
os.Exit(1)
}
}
func findRepoRoot() (string, error) {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("unable to locate copydir.go source path")
}
scriptDir := filepath.Dir(file)
candidate := filepath.Clean(filepath.Join(scriptDir, ".."))
if err := validateRepoRoot(candidate); err == nil {
return candidate, nil
}
wd, err := os.Getwd()
if err != nil {
return "", err
}
cur, err := filepath.Abs(wd)
if err != nil {
return "", err
}
for {
if err := validateRepoRoot(cur); err == nil {
return filepath.Clean(cur), nil
}
parent := filepath.Dir(cur)
if parent == cur {
return "", fmt.Errorf("could not find repository root from %s", wd)
}
cur = parent
}
}
func validateRepoRoot(root string) error {
anchors := []string{
filepath.Join(root, "go.sum"),
filepath.Join(root, "LICENSE"),
filepath.Join(root, ".github"),
}
for _, anchor := range anchors {
if _, err := os.Stat(anchor); err != nil {
return fmt.Errorf("missing repo anchor %s: %w", anchor, err)
}
}
return nil
}
func normalizePathArg(arg, repoRoot string) (string, error) {
resolved := strings.ReplaceAll(arg, "${codespace}", repoRoot)
abs, err := filepath.Abs(resolved)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
func ensurePathWithinRepo(repoRoot, path string) error {
rel, err := filepath.Rel(repoRoot, path)
if err != nil {
return err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("path %s is outside repository root %s", path, repoRoot)
}
return nil
}
func samePath(a, b string) bool {
return filepath.Clean(a) == filepath.Clean(b)
}
func copyTree(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("source is not a directory: %s", src)
}
return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := dst
if rel != "." {
target = filepath.Join(dst, rel)
}
if entry.IsDir() {
return os.MkdirAll(target, entry.Mode())
}
return copyFile(path, target, entry.Mode())
})
}
func copyFile(src, dst string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}

View file

@ -2,15 +2,24 @@
build-android-arm64 build-android-bundle
# 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

View file

@ -0,0 +1,11 @@
//go:build !windows
package api
import "os/exec"
func launcherExecCommand(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
}
func applyLauncherProcAttrs(_ *exec.Cmd) {}

View file

@ -0,0 +1,24 @@
//go:build windows
package api
import (
"os/exec"
"syscall"
)
func launcherExecCommand(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...)
applyLauncherProcAttrs(cmd)
return cmd
}
func applyLauncherProcAttrs(cmd *exec.Cmd) {
if cmd == nil {
return
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.HideWindow = true
}

View file

@ -164,7 +164,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) {
`$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`,
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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/sipeed/picoclaw/pkg/tools"
)
type uiLanguageRequest struct {
Language string `json:"language"`
}
func (h *Handler) registerUIRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/ui/language", h.handleSetUILanguage)
}
func (h *Handler) handleSetUILanguage(w http.ResponseWriter, r *http.Request) {
var req uiLanguageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
tools.SetPreferredWebSearchLanguage(req.Language)
w.WriteHeader(http.StatusNoContent)
}

View file

@ -1,48 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/tools"
)
func TestHandleSetUILanguage(t *testing.T) {
tools.SetPreferredWebSearchLanguage("")
t.Cleanup(func() {
tools.SetPreferredWebSearchLanguage("")
})
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{"language":"zh"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
if got := tools.GetPreferredWebSearchLanguage(); got != "zh" {
t.Fatalf("preferred web search language = %q, want zh", got)
}
}
func TestHandleSetUILanguage_RejectsInvalidJSON(t *testing.T) {
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}

View file

@ -29,7 +29,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/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()

View file

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

View file

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

View file

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

View 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) : ""

View file

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

View file

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

View file

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