diff --git a/.dockerignore b/.dockerignore index d632da5ea..9ddc971ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ +# Do NOT exclude LICENSE or .github — scripts/copydir.go uses them as repo-root anchors +# during `go generate`, which runs inside `make build` in the Dockerfile. .git .gitignore build/ @@ -6,5 +8,4 @@ config/ .env .env.example *.md -LICENSE assets/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3d72ace94 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure shell scripts always use LF line endings regardless of OS. +*.sh text eol=lf +docker/entrypoint.sh text eol=lf diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..f454f5977 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,64 @@ +name: Close stale issues and PRs + +on: + schedule: + # Run daily at 03:00 JST (18:00 UTC) + - cron: "0 18 * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + + steps: + - name: Mark and close stale issues and PRs + uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # ── Issue: 7 days inactive → stale; 7 more days → close ── + days-before-issue-stale: 7 + days-before-issue-close: 7 + stale-issue-label: "stale" + stale-issue-message: > + This issue has had no activity for 7 days and has been marked as stale. + If it is still relevant, please reply or update; otherwise it will be + closed automatically in 7 days. + close-issue-message: > + This issue has been closed after 14 days of inactivity. + If it is still needed, feel free to reopen it anytime. + close-issue-reason: "not_planned" + + # ── PR: 7 days inactive → stale; 7 more days → close ── + days-before-pr-stale: 7 + days-before-pr-close: 7 + stale-pr-label: "stale" + stale-pr-message: > + This PR has had no activity for 7 days and has been marked as stale. + If you are still working on it, please push an update or leave a comment; + otherwise it will be closed automatically in 7 days. + close-pr-message: > + This PR has been closed after 14 days of inactivity. + If you would like to continue, feel free to reopen it or submit a new PR. + + # ── Protected labels (exempt from stale processing) ── + exempt-issue-labels: "pinned,keep-open,wip,do-not-close,type: roadmap" + exempt-pr-labels: "pinned,keep-open,wip,do-not-close,type: roadmap" + + # ── Exempt draft PRs ── + exempt-draft-pr: true + + # ── Remove stale label when activity resumes ── + remove-stale-when-updated: true + remove-issue-stale-when-updated: true + remove-pr-stale-when-updated: true + + # ── Scan oldest items first so old stale items are not starved ── + ascending: true + + # ── Throttle: max operations per run ── + operations-per-run: 500 diff --git a/.gitignore b/.gitignore index 135867842..e1736f56b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ dist/ # Windows Application Icon/Resource *.syso +.cache/ +web/frontend/.pnpm-store/ +_tmp_* +web/frontend/_tmp_* # Test telegram integration cmd/telegram/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d8c51b069..fe43a0921 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -100,49 +100,6 @@ builds: - goos: netbsd goarch: arm - - id: picoclaw-launcher-tui - binary: picoclaw-launcher-tui - env: - - CGO_ENABLED=0 - tags: - - goolm - - stdjson - ldflags: - - -s -w - - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} - - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} - - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} - - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }} - goos: - - linux - - windows - - darwin - - freebsd - - netbsd - goarch: - - amd64 - - arm64 - - riscv64 - - loong64 - - arm - - s390x - - mipsle - goarm: - - "6" - - "7" - gomips: - - softfloat - main: ./cmd/picoclaw-launcher-tui - ignore: - - goos: windows - goarch: arm - - goos: netbsd - goarch: s390x - - goos: netbsd - goarch: mips64 - - goos: netbsd - goarch: arm - dockers_v2: - id: picoclaw dockerfile: docker/Dockerfile.goreleaser @@ -166,7 +123,6 @@ dockers_v2: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui images: - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' @@ -184,7 +140,6 @@ notarize: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui sign: certificate: "{{.Env.MACOS_SIGN_P12}}" password: "{{.Env.MACOS_SIGN_PASSWORD}}" @@ -215,7 +170,6 @@ nfpms: ids: - picoclaw - picoclaw-launcher - - picoclaw-launcher-tui package_name: picoclaw file_name_template: >- {{ .PackageName }}_ diff --git a/Makefile b/Makefile index c5d691c29..3fa41bc24 100644 --- a/Makefile +++ b/Makefile @@ -7,19 +7,43 @@ CMD_DIR=cmd/$(BINARY_NAME) MAIN_GO=$(CMD_DIR)/main.go EXT= +ifeq ($(OS),Windows_NT) + POWERSHELL=powershell -NoProfile -Command + WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL)) +endif + # Version -VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") -BUILD_TIME=$(shell date +%FT%T%z) -GO_VERSION=$(shell $(GO) version | awk '{print $$3}') +ifeq ($(OS),Windows_NT) + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL)) + BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'")) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL)) +else + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null)) + BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z)) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null)) +endif +VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev) +GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev) +BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev) +GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown) CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w # Go variables -GO?=CGO_ENABLED=0 go +GO?=go WEB_GO?=$(GO) +CGO_ENABLED?=0 GO_BUILD_TAGS?=goolm,stdjson GOFLAGS?=-v -tags $(GO_BUILD_TAGS) +GOCACHE?=$(CURDIR)/.cache/go-build +GOMODCACHE?=$(CURDIR)/.cache/go-mod +GOTOOLCHAIN?=local +export CGO_ENABLED +export GOCACHE +export GOMODCACHE +export GOTOOLCHAIN comma:=, empty:= space:=$(empty) $(empty) @@ -73,8 +97,21 @@ BUILTIN_SKILLS_DIR=$(CURDIR)/skills LNCMD=ln -sf # OS detection -UNAME_S?=$(shell uname -s) -UNAME_M?=$(shell uname -m) +ifeq ($(OS),Windows_NT) + UNAME_S=Windows + ifeq ($(WINDOWS_GOARCH_RAW),amd64) + UNAME_M=x86_64 + else ifeq ($(WINDOWS_GOARCH_RAW),arm64) + UNAME_M=arm64 + else ifeq ($(WINDOWS_GOARCH_RAW),386) + UNAME_M=x86 + else + UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64) + endif +else + UNAME_S?=$(shell uname -s) + UNAME_M?=$(shell uname -m) +endif # Platform-specific settings ifeq ($(UNAME_S),Linux) @@ -122,6 +159,30 @@ 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 + +ifneq ($(strip $(GOOS)),) + PLATFORM:=$(GOOS) +endif + +ifneq ($(strip $(GOARCH)),) + ARCH:=$(GOARCH) +endif + +ifeq ($(PLATFORM),windows) + EXT=.exe +endif + BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH) # Default target @@ -130,41 +191,51 @@ 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 }" + @$(POWERSHELL) "$$env:GOOS=''; $$env:GOARCH=''; $(GO) generate ./..." +else @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true - @$(GO) generate ./... + @GOOS=$$($(GO) env GOHOSTOS) GOARCH=$$($(GO) env GOHOSTARCH) $(GO) generate ./... +endif @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) + @GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)$(EXT)" @$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT) +endif + @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 \ + @GOOS=$(PLATFORM) GOARCH=$(ARCH) $(MAKE) -C web build \ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \ WEB_GO='$(WEB_GO)' \ GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ 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: @$(MAKE) -C web build-frontend -## build-launcher-tui: Build the picoclaw-launcher TUI binary -build-launcher-tui: - @echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..." - @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" - ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." @@ -290,7 +361,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 diff --git a/README.md b/README.md index 5aac4bbc9..30ac67d8f 100644 --- a/README.md +++ b/README.md @@ -291,24 +291,6 @@ After this one-time step, `picoclaw-launcher` will open normally on subsequent l -### 💻 TUI Launcher (Recommended for Headless / SSH) - -The TUI (Terminal UI) Launcher provides a full-featured terminal interface for configuration and management. Ideal for servers, Raspberry Pi, and other headless environments. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

- -**Getting started:** - -Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat! - -For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io). - ### 📱 Android @@ -571,7 +553,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). ## ClawdChat Join the Agent Social Network @@ -591,6 +586,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 +619,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 | diff --git a/assets/launcher-tui.jpg b/assets/launcher-tui.jpg deleted file mode 100644 index 659c97794..000000000 Binary files a/assets/launcher-tui.jpg and /dev/null differ diff --git a/assets/wechat.png b/assets/wechat.png index c41288547..b368f75d3 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw-launcher-tui/README.md b/cmd/picoclaw-launcher-tui/README.md deleted file mode 100644 index a942045a5..000000000 --- a/cmd/picoclaw-launcher-tui/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Picoclaw Launcher TUI - -This directory contains the terminal-based TUI launcher for `picoclaw`. -It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment. - -## Architecture - -The TUI launcher is implemented purely in Go with no external runtime dependencies: -* **`main.go`**: Application entry point, handles initialization and main event loop -* **`ui/`**: TUI interface components built on tview + tcell framework: - - `home.go`: Main dashboard with navigation menu - - `schemes.go`: AI model scheme management - - `users.go`: User and API key management for model providers - - `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor - - `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status) - - `app.go`: Core TUI application framework and navigation logic - - `models.go`: Data structures and state management -* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system - -## Getting Started - -### Prerequisites - -* Go 1.25+ -* Terminal with 256-color support (most modern terminals are compatible) - -### Development - -Run the TUI launcher directly in development mode: - -```bash -# From project root -go run ./cmd/picoclaw-launcher-tui - -# Or from this directory -go run . -``` - -### Build - -Build the standalone TUI launcher binary: - -```bash -# From project root (recommended) -make build-launcher-tui - -# Output will be at: -# build/picoclaw-launcher-tui-- -# with symlink build/picoclaw-launcher-tui - -# Or build directly from this directory -go build -o picoclaw-launcher-tui . -``` - -### Key Features - -* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments -* ⚙️ AI model scheme and API key management -* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.) -* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring) -* 💬 One-click launch of interactive AI chat session -* 🎯 Keyboard-first design with intuitive shortcuts - -### Other Commands - -```bash -# Run with custom config file path -go run . /path/to/custom/config.json -``` diff --git a/cmd/picoclaw-launcher-tui/config/config.go b/cmd/picoclaw-launcher-tui/config/config.go deleted file mode 100644 index 227b9fa3d..000000000 --- a/cmd/picoclaw-launcher-tui/config/config.go +++ /dev/null @@ -1,236 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -// Package config provides types and I/O for ~/.picoclaw/tui.toml. -package config - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/BurntSushi/toml" - - "github.com/sipeed/picoclaw/pkg/fileutil" -) - -// DefaultConfigPath returns the default path to the tui.toml config file. -func DefaultConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - return filepath.Join(home, ".picoclaw", "tui.toml") -} - -// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml. -type TUIConfig struct { - Version string `toml:"version"` - Model Model `toml:"model"` - Provider Provider `toml:"provider"` -} - -type Model struct { - Type string `toml:"type"` // "provider" (default) | "manual" -} - -type Provider struct { - Schemes []Scheme `toml:"schemes"` - Users []User `toml:"users"` - Current ProviderCurrent `toml:"current"` -} - -type Scheme struct { - Name string `toml:"name"` // unique key - BaseURL string `toml:"baseURL"` // required - Type string `toml:"type"` // "openai-compatible" (default) | "anthropic" -} - -type User struct { - Name string `toml:"name"` - Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique - Type string `toml:"type"` // "key" (default) | "OAuth" - Key string `toml:"key"` -} - -type ProviderCurrent struct { - Scheme string `toml:"scheme"` // references Scheme.Name - User string `toml:"user"` // references User.Name where User.Scheme == Scheme - Model string `toml:"model"` // from GET /models -} - -// DefaultConfig returns a minimal valid TUIConfig. -func DefaultConfig() *TUIConfig { - return &TUIConfig{ - Version: "1.0", - Model: Model{Type: "provider"}, - Provider: Provider{ - Schemes: []Scheme{}, - Users: []User{}, - Current: ProviderCurrent{}, - }, - } -} - -// Load reads the TUI config from path. Returns a default config if the file does not exist. -func Load(path string) (*TUIConfig, error) { - data, err := os.ReadFile(path) - if os.IsNotExist(err) { - return DefaultConfig(), nil - } - if err != nil { - return nil, fmt.Errorf("failed to read config file %q: %w", path, err) - } - - cfg := DefaultConfig() - if _, err := toml.Decode(string(data), cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file %q: %w", path, err) - } - - applyDefaults(cfg) - return cfg, nil -} - -// Save writes cfg to path atomically (safe for flash / SD storage). -func Save(path string, cfg *TUIConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - var buf bytes.Buffer - enc := toml.NewEncoder(&buf) - if err := enc.Encode(cfg); err != nil { - return fmt.Errorf("failed to encode config: %w", err) - } - if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil { - return fmt.Errorf("failed to write config file %q: %w", path, err) - } - return nil -} - -func applyDefaults(cfg *TUIConfig) { - if cfg.Version == "" { - cfg.Version = "1.0" - } - if cfg.Model.Type == "" { - cfg.Model.Type = "provider" - } - for i := range cfg.Provider.Schemes { - if cfg.Provider.Schemes[i].Type == "" { - cfg.Provider.Schemes[i].Type = "openai-compatible" - } - } - for i := range cfg.Provider.Users { - if cfg.Provider.Users[i].Type == "" { - cfg.Provider.Users[i].Type = "key" - } - } -} - -// SchemeByName returns the first Scheme whose Name matches, or nil. -func (p *Provider) SchemeByName(name string) *Scheme { - for i := range p.Schemes { - if p.Schemes[i].Name == name { - return &p.Schemes[i] - } - } - return nil -} - -// UsersForScheme returns all users whose Scheme field matches schemeName. -func (p *Provider) UsersForScheme(schemeName string) []User { - var out []User - for _, u := range p.Users { - if u.Scheme == schemeName { - out = append(out, u) - } - } - return out -} - -// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json -// Adds/replaces a "tui-prefer" model entry and sets it as the default model. -// Preserves all other existing fields in the config file unchanged. -func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error { - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - mainConfigPath := filepath.Join(home, ".picoclaw", "config.json") - - var cfg map[string]any - if data, readErr := os.ReadFile(mainConfigPath); readErr == nil { - if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil { - cfg = make(map[string]any) - } - } else { - cfg = make(map[string]any) - } - - if _, ok := cfg["agents"]; !ok { - cfg["agents"] = make(map[string]any) - } - agents, ok := cfg["agents"].(map[string]any) - if ok { - if _, ok := agents["defaults"]; !ok { - agents["defaults"] = make(map[string]any) - } - defaults, ok := agents["defaults"].(map[string]any) - if ok { - defaults["model"] = "tui-prefer" - } - } - - tuiModel := map[string]any{ - "model_name": "tui-prefer", - "model": modelID, - "api_key": user.Key, - "api_base": scheme.BaseURL, - } - - modelList := []any{} - if ml, ok := cfg["model_list"].([]any); ok { - modelList = ml - } - - found := false - for i, m := range modelList { - if entry, ok := m.(map[string]any); ok { - if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" { - modelList[i] = tuiModel - found = true - break - } - } - } - if !found { - modelList = append(modelList, tuiModel) - } - cfg["model_list"] = modelList - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil { - return err - } - - return os.WriteFile(mainConfigPath, data, 0o600) -} - -func (cfg *TUIConfig) CurrentModelLabel() string { - cur := cfg.Provider.Current - if cur.Model == "" { - return "(not configured)" - } - label := cur.Scheme - if label != "" { - label += " / " - } - return label + cur.Model -} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go deleted file mode 100644 index 3cb7110c1..000000000 --- a/cmd/picoclaw-launcher-tui/main.go +++ /dev/null @@ -1,48 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" - "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui" -) - -func main() { - configPath := tuicfg.DefaultConfigPath() - if len(os.Args) > 1 { - configPath = os.Args[1] - } - - configDir := filepath.Dir(configPath) - if _, err := os.Stat(configDir); os.IsNotExist(err) { - cmd := exec.Command("picoclaw", "onboard") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - } - - cfg, err := tuicfg.Load(configPath) - if err != nil { - fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) - os.Exit(1) - } - - app := ui.New(cfg, configPath) - // Bind model selection hook to sync to main config - app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) { - _ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID) - } - if err := app.Run(); err != nil { - fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/picoclaw-launcher-tui/ui/app.go b/cmd/picoclaw-launcher-tui/ui/app.go deleted file mode 100644 index a65693b01..000000000 --- a/cmd/picoclaw-launcher-tui/ui/app.go +++ /dev/null @@ -1,325 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - "sync" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -// App is the root TUI application. -type App struct { - tapp *tview.Application - pages *tview.Pages - pageStack []string - cfg *tuicfg.TUIConfig - configPath string - pageRefreshFns map[string]func() - headerModelTV *tview.TextView - modalOpen map[string]bool - - // OnModelSelected is called when a model is selected in the UI. - // Can be nil to disable. - OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) - - modelCache map[string][]modelEntry - modelCacheMu sync.RWMutex - refreshMu sync.Mutex -} - -// cacheKey returns the map key for a (scheme, user) pair. -func cacheKey(schemeName, userName string) string { - return fmt.Sprintf("%s/%s", schemeName, userName) -} - -// cachedModels returns a defensive copy of the cached model list for a user (may be nil). -func (a *App) cachedModels(schemeName, userName string) []modelEntry { - a.modelCacheMu.RLock() - defer a.modelCacheMu.RUnlock() - entries := a.modelCache[cacheKey(schemeName, userName)] - return append([]modelEntry(nil), entries...) -} - -// refreshModelCache fetches models for every user in the config concurrently. -// Serialized by refreshMu so concurrent calls don't race on the cache map. -// When all fetches complete it calls onDone via QueueUpdateDraw. -func (a *App) refreshModelCache(onDone func()) { - go func() { - a.refreshMu.Lock() - defer a.refreshMu.Unlock() - - users := a.cfg.Provider.Users - schemes := a.cfg.Provider.Schemes - - schemeURL := make(map[string]string, len(schemes)) - for _, s := range schemes { - schemeURL[s.Name] = s.BaseURL - } - - var wg sync.WaitGroup - for _, u := range users { - baseURL, ok := schemeURL[u.Scheme] - if !ok || baseURL == "" { - continue - } - if u.Key == "" { - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - a.modelCache[cacheKey(u.Scheme, u.Name)] = nil - a.modelCacheMu.Unlock() - continue - } - wg.Add(1) - bURL := baseURL - go func() { - defer wg.Done() - entries, err := fetchModels(bURL, u.Key) - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - if err != nil || len(entries) == 0 { - a.modelCache[cacheKey(u.Scheme, u.Name)] = nil - } else { - a.modelCache[cacheKey(u.Scheme, u.Name)] = entries - } - a.modelCacheMu.Unlock() - }() - } - wg.Wait() - - if onDone != nil { - a.tapp.QueueUpdateDraw(onDone) - } - }() -} - -// New creates and wires up the TUI application. -func New(cfg *tuicfg.TUIConfig, configPath string) *App { - // Cyberpunk Theme Colors - // Dark background - tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void - tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo - tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40) - - // Borders and Titles - tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta - - // Text - tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white - tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan - tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime - tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black - tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta - - a := &App{ - tapp: tview.NewApplication(), - pages: tview.NewPages(), - pageStack: []string{}, - cfg: cfg, - configPath: configPath, - pageRefreshFns: make(map[string]func()), - modalOpen: make(map[string]bool), - } - - a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - if len(a.modalOpen) > 0 { - return event - } - return a.goBack() - } - return event - }) - - a.buildPages() - return a -} - -// Run starts the TUI event loop. -func (a *App) Run() error { - return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run() -} - -func (a *App) buildPages() { - a.pages.AddPage("home", a.newHomePage(), true, true) - a.pageStack = []string{"home"} -} - -func (a *App) navigateTo(name string, page tview.Primitive) { - a.pages.RemovePage(name) - a.pages.AddPage(name, page, true, false) - a.pageStack = append(a.pageStack, name) - a.pages.SwitchToPage(name) -} - -func (a *App) goBack() *tcell.EventKey { - if len(a.pageStack) <= 1 { - return nil - } - popped := a.pageStack[len(a.pageStack)-1] - a.pageStack = a.pageStack[:len(a.pageStack)-1] - a.pages.RemovePage(popped) - prev := a.pageStack[len(a.pageStack)-1] - if fn, ok := a.pageRefreshFns[prev]; ok { - fn() - } - if prev == "home" && a.headerModelTV != nil { - a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ") - } - a.pages.SwitchToPage(prev) - return nil -} - -func (a *App) showModal(name string, primitive tview.Primitive) { - a.modalOpen[name] = true - a.pages.AddPage(name, primitive, true, true) -} - -func (a *App) hideModal(name string) { - delete(a.modalOpen, name) - a.pages.HidePage(name) - a.pages.RemovePage(name) -} - -func (a *App) save() { - if err := tuicfg.Save(a.configPath, a.cfg); err != nil { - a.showError("save failed: " + err.Error()) - } -} - -func (a *App) showError(msg string) { - modal := tview.NewModal(). - SetText(" [red::b]ERROR[-::-]\n\n" + msg). - AddButtons([]string{"OK"}). - SetDoneFunc(func(_ int, _ string) { - a.hideModal("error") - }) - // Cyberpunk Modal Style - modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White - modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red - modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White - a.showModal("error", modal) -} - -func (a *App) confirmDelete(label string, onConfirm func()) { - modal := tview.NewModal(). - SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]"). - AddButtons([]string{"Delete", "Cancel"}). - SetDoneFunc(func(_ int, buttonLabel string) { - a.hideModal("confirm-delete") - if buttonLabel == "Delete" { - onConfirm() - } - }) - // Cyberpunk Modal Style - modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White - modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger - modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White - a.showModal("confirm-delete", modal) -} - -func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive { - return tview.NewFlex(). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(form, height, 1, true). - AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true). - AddItem(tview.NewBox(), 0, 1, false) -} - -func hintBar(text string) *tview.TextView { - tv := tview.NewTextView(). - SetText(text). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan - tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo - return tv -} - -func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive { - var modelTV *tview.TextView - if pageID == "home" { - if a.headerModelTV == nil { - a.headerModelTV = tview.NewTextView() - a.headerModelTV.SetTextAlign(tview.AlignRight). - SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime - SetDynamicColors(true). - SetBackgroundColor(tcell.NewHexColor(0x050510)) - } - modelTV = a.headerModelTV - modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ") - } else { - modelTV = tview.NewTextView() - modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) - } - - headerLeft := tview.NewTextView(). - SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///"). - SetDynamicColors(true). - SetBackgroundColor(tcell.NewHexColor(0x050510)) - - header := tview.NewFlex(). - AddItem(headerLeft, 0, 1, false). - AddItem(modelTV, 0, 1, false) - - sidebar := tview.NewTextView(). - SetDynamicColors(true). - SetWrap(false) - sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo - - // Cyberpunk Sidebar Styling - activePrefix := "[#39ff14::b]>> " // Neon Lime arrow - activeSuffix := "[-]" - inactivePrefix := "[#808080] " - inactiveSuffix := "[-]" - - sbText := "\n\n" // Top padding - - menuItem := func(id, label string) string { - if pageID == id { - return activePrefix + label + activeSuffix + "\n\n" - } - return inactivePrefix + label + inactiveSuffix + "\n\n" - } - - sbText += menuItem("home", "HOME") - sbText += menuItem("schemes", "SCHEMES") - sbText += menuItem("users", "USERS") - sbText += menuItem("models", "MODELS") - sbText += menuItem("channels", "CHANNELS") - sbText += menuItem("gateway", "GATEWAY") - - sidebar.SetText(sbText) - - footer := hintBar(hint) - - grid := tview.NewGrid(). - SetRows(1, 0, 1). - SetColumns(20, 0). // Slightly wider sidebar - AddItem(header, 0, 0, 1, 2, 0, 0, false). - AddItem(sidebar, 1, 0, 1, 1, 0, 0, false). - AddItem(content, 1, 1, 1, 1, 0, 0, true). - AddItem(footer, 2, 0, 1, 2, 0, 0, false) - - // Add a border around the content area if possible, or ensure content has its own border - // grid.SetBorders(false) // Grid borders usually look bad, handled by components - - return grid -} diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go deleted file mode 100644 index c976f1fcd..000000000 --- a/cmd/picoclaw-launcher-tui/ui/channels.go +++ /dev/null @@ -1,202 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "strconv" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func (a *App) newChannelsPage() tview.Primitive { - list := tview.NewList() - list.SetBorder(true). - SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) - list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)), - ) - list.SetHighlightFullLine(true) - list.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rebuild := func() { - sel := list.GetCurrentItem() - list.Clear() - - home, err := os.UserHomeDir() - if err != nil { - home = "." - } - configPath := filepath.Join(home, ".picoclaw", "config.json") - - var cfg map[string]any - if data, err := os.ReadFile(configPath); err == nil { - _ = json.Unmarshal(data, &cfg) - } - - if chRaw, ok := cfg["channels"].(map[string]any); ok { - for name, ch := range chRaw { - chMap, ok := ch.(map[string]any) - enabled := "disabled" - if ok { - if e, ok := chMap["enabled"].(bool); ok && e { - enabled = "enabled" - } - } - list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() { - a.showChannelEditForm(configPath, name, chMap) - }) - } - } - - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) - } - } - rebuild() - - a.pageRefreshFns["channels"] = rebuild - - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - return a.goBack() - } - return event - }) - - return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ") -} - -func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) { - form := tview.NewForm() - form.SetBorder(true). - SetTitle(" [::b]EDIT CHANNEL "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - - fields := make(map[string]*tview.InputField) - var nameField *tview.InputField - - if channelName == "" { - nameField = tview.NewInputField(). - SetLabel("Channel Name"). - SetText(""). - SetFieldWidth(28) - form.AddFormItem(nameField) - } - - for k, v := range existing { - if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice { - continue - } - valStr := fmt.Sprintf("%v", v) - field := tview.NewInputField(). - SetLabel(k). - SetText(valStr). - SetFieldWidth(28) - form.AddFormItem(field) - fields[k] = field - } - - form.AddButton("SAVE", func() { - var cfg map[string]any - if data, err := os.ReadFile(configPath); err == nil { - if err := json.Unmarshal(data, &cfg); err != nil { - cfg = make(map[string]any) - } - } else { - cfg = make(map[string]any) - } - - if _, ok := cfg["channels"]; !ok { - cfg["channels"] = make(map[string]any) - } - channels, ok := cfg["channels"].(map[string]any) - if !ok { - channels = make(map[string]any) - cfg["channels"] = channels - } - - finalName := channelName - if channelName == "" { - if nameField == nil || nameField.GetText() == "" { - a.showError("Channel name is required") - return - } - finalName = nameField.GetText() - } - - updated := make(map[string]any) - if existing != nil { - for k, v := range existing { - updated[k] = v - } - } - for k, field := range fields { - val := field.GetText() - if val == "true" { - updated[k] = true - } else if val == "false" { - updated[k] = false - } else if num, err := strconv.Atoi(val); err == nil { - updated[k] = num - } else { - updated[k] = val - } - } - - if channelName != "" && finalName != channelName { - delete(channels, channelName) - } - channels[finalName] = updated - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - a.showError(fmt.Sprintf("Failed to save config: %v", err)) - return - } - if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { - a.showError(fmt.Sprintf("Failed to create config directory: %v", err)) - return - } - if err := os.WriteFile(configPath, data, 0o600); err != nil { - a.showError(fmt.Sprintf("Failed to write config: %v", err)) - return - } - - a.hideModal("channel-edit") - a.goBack() - }) - - form.AddButton("CANCEL", func() { - a.hideModal("channel-edit") - }) - - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("channel-edit") - return nil - } - return event - }) - - a.showModal("channel-edit", centeredForm(form, 4, 20)) -} diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go deleted file mode 100644 index 781204bf2..000000000 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ /dev/null @@ -1,229 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - "os/exec" - "runtime" - "strconv" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - "github.com/sipeed/picoclaw/pkg/config" - ppid "github.com/sipeed/picoclaw/pkg/pid" -) - -type gatewayStatus struct { - running bool - pid int - version string -} - -func picoHome() string { - return config.GetHome() -} - -func getGatewayStatus() gatewayStatus { - data := ppid.ReadPidFileWithCheck(picoHome()) - if data == nil { - return gatewayStatus{running: false} - } - return gatewayStatus{ - running: true, - pid: data.PID, - version: data.Version, - } -} - -func startGateway() error { - status := getGatewayStatus() - if status.running { - return fmt.Errorf("gateway is already running (PID: %d)", status.pid) - } - - var cmd *exec.Cmd - - if runtime.GOOS == "windows" { - cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1") - } else { - cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &") - } - - err := cmd.Start() - if err != nil { - return err - } - - time.Sleep(1 * time.Second) - - if runtime.GOOS == "windows" { - cmd := exec.Command( - "wmic", - "process", - "where", - "name='picoclaw.exe' and commandline like '%gateway%'", - "get", - "processid", - ) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to get gateway PID: %w", err) - } - lines := strings.Split(string(output), "\n") - for _, line := range lines[1:] { - line = strings.TrimSpace(line) - if line == "" { - continue - } - _, err := strconv.Atoi(line) - if err == nil { - break - } - } - } - - status = getGatewayStatus() - if !status.running { - return fmt.Errorf("failed to start gateway") - } - return nil -} - -func stopGateway() error { - status := getGatewayStatus() - if !status.running { - return fmt.Errorf("gateway is not running") - } - - var err error - if runtime.GOOS == "windows" { - err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run() - } else { - err = exec.Command("kill", strconv.Itoa(status.pid)).Run() - } - if err != nil { - return err - } - - // Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file) - for i := 0; i < 5; i++ { - if !getGatewayStatus().running { - break - } - time.Sleep(200 * time.Millisecond) - } - - return nil -} - -func (a *App) newGatewayPage() tview.Primitive { - flex := tview.NewFlex().SetDirection(tview.FlexRow) - flex.SetBorder(true). - SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - flex.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - statusTV := tview.NewTextView(). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter). - SetText("Checking status...") - statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - var updateStatus func() - - // 使用List作为按钮,保证显示和交互正常 - buttons := tview.NewList() - buttons.SetBackgroundColor(tcell.NewHexColor(0x050510)) - buttons.SetMainTextColor(tcell.ColorWhite) - buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff)) - buttons.SetSelectedTextColor(tcell.ColorBlack) - - buttons.AddItem(" [lime]START[white] ", "", 0, func() { - if !getGatewayStatus().running { - err := startGateway() - if err != nil { - a.showError(err.Error()) - } - updateStatus() - } - }) - buttons.AddItem(" [red]STOP[white] ", "", 0, func() { - if getGatewayStatus().running { - err := stopGateway() - if err != nil { - a.showError(err.Error()) - } - updateStatus() - } - }) - - buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn) - buttonFlex. - AddItem(tview.NewBox(), 0, 1, false). - AddItem(buttons, 20, 1, true). - AddItem(tview.NewBox(), 0, 1, false) - - flex. - AddItem(tview.NewBox(), 0, 1, false). - AddItem(statusTV, 3, 1, false). - AddItem(tview.NewBox(), 0, 1, false). - AddItem(buttonFlex, 4, 1, true). - AddItem(tview.NewBox(), 0, 1, false) - - updateStatus = func() { - status := getGatewayStatus() - if status.running { - versionInfo := "" - if status.version != "" { - versionInfo = fmt.Sprintf("\nVersion: %s", status.version) - } - statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo)) - buttons.SetItemText(0, " [gray]START[white] ", "") - buttons.SetItemText(1, " [red]STOP[white] ", "") - } else { - statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A") - buttons.SetItemText(0, " [lime]START[white] ", "") - buttons.SetItemText(1, " [gray]STOP[white] ", "") - } - } - - updateStatus() - - done := make(chan struct{}) - go func() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - for { - select { - case <-ticker.C: - a.tapp.QueueUpdateDraw(updateStatus) - case <-done: - return - } - } - }() - - originalInputCapture := flex.GetInputCapture() - flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - close(done) - return a.goBack() - } - if originalInputCapture != nil { - return originalInputCapture(event) - } - return event - }) - - a.pageRefreshFns["gateway"] = updateStatus - - return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ") -} diff --git a/cmd/picoclaw-launcher-tui/ui/home.go b/cmd/picoclaw-launcher-tui/ui/home.go deleted file mode 100644 index 74a7769cf..000000000 --- a/cmd/picoclaw-launcher-tui/ui/home.go +++ /dev/null @@ -1,70 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "os" - "os/exec" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func (a *App) newHomePage() tview.Primitive { - list := tview.NewList() - list.SetBorder(true). - SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0)) - list.SetSecondaryTextColor(tcell.NewHexColor(0x808080)) - list.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)), - ) - list.SetHighlightFullLine(true) - list.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rebuildList := func() { - sel := list.GetCurrentItem() - list.Clear() - list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() { - a.navigateTo("schemes", a.newSchemesPage()) - }) - list.AddItem( - "CHANNELS: Configure communication channels", - "Manage Telegram/Discord/WeChat channels", - 'n', - func() { - a.navigateTo("channels", a.newChannelsPage()) - }, - ) - list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() { - a.navigateTo("gateway", a.newGatewayPage()) - }) - list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() { - a.tapp.Suspend(func() { - cmd := exec.Command("picoclaw", "agent") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - _ = cmd.Run() - }) - }) - list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() }) - if sel >= 0 && sel < list.GetItemCount() { - list.SetCurrentItem(sel) - } - } - rebuildList() - - a.pageRefreshFns["home"] = rebuildList - - return a.buildShell( - "home", - list, - " [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ", - ) -} diff --git a/cmd/picoclaw-launcher-tui/ui/models.go b/cmd/picoclaw-launcher-tui/ui/models.go deleted file mode 100644 index 20e5f0182..000000000 --- a/cmd/picoclaw-launcher-tui/ui/models.go +++ /dev/null @@ -1,200 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -type modelsAPIResponse struct { - Data []modelEntry `json:"data"` -} - -type modelEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` -} - -func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false). - SetFixed(0, 0) - table.SetBorder(true). - SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - var modelIDs []string - - status := tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetDynamicColors(true). - SetText("[#ffff00]FETCHING MODELS...[-]") - status.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - flex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(status, 1, 0, false). - AddItem(table, 0, 1, false) - - apiKey := a.resolveKey(schemeName, userName) - - go func() { - var entries []modelEntry - var err error - if apiKey == "" { - err = fmt.Errorf("key is required") - } else { - entries, err = fetchModels(baseURL, apiKey) - } - - a.modelCacheMu.Lock() - if a.modelCache == nil { - a.modelCache = make(map[string][]modelEntry) - } - if err == nil && len(entries) > 0 { - a.modelCache[cacheKey(schemeName, userName)] = entries - } else { - a.modelCache[cacheKey(schemeName, userName)] = nil - } - a.modelCacheMu.Unlock() - - a.tapp.QueueUpdateDraw(func() { - if err != nil { - status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error())) - table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)")) - a.tapp.SetFocus(table) - return - } - if len(entries) == 0 { - status.SetText("[#ff2a2a]NO MODELS RETURNED[-]") - table.SetCell(0, 0, tview.NewTableCell(" (no models available)")) - a.tapp.SetFocus(table) - return - } - - status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries))) - for i, m := range entries { - modelIDs = append(modelIDs, m.ID) - table.SetCell(i, 0, - tview.NewTableCell(fmt.Sprintf("%3d", i+1)). - SetAlign(tview.AlignRight). - SetTextColor(tcell.NewHexColor(0x808080)). - SetSelectable(false), - ) - table.SetCell(i, 1, - tview.NewTableCell(" "+m.ID). - SetAlign(tview.AlignLeft). - SetExpansion(1). - SetTextColor(tcell.NewHexColor(0xe0e0e0)), - ) - } - a.tapp.SetFocus(table) - }) - }() - - table.SetSelectedFunc(func(row, _ int) { - if row < 0 || row >= len(modelIDs) { - return - } - a.cfg.Provider.Current = tuicfg.ProviderCurrent{ - Scheme: schemeName, - User: userName, - Model: modelIDs[row], - } - a.save() - - // Trigger model selected callback if set - if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" { - scheme := a.cfg.Provider.SchemeByName(schemeName) - if scheme == nil { - a.goBack() - return - } - var user tuicfg.User - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - user = u - break - } - } - a.OnModelSelected(*scheme, user, modelIDs[row]) - } - - a.goBack() - }) - - return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ") -} - -func (a *App) resolveKey(schemeName, userName string) string { - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - return u.Key - } - } - return "" -} - -func fetchModels(baseURL, apiKey string) ([]modelEntry, error) { - url := strings.TrimRight(baseURL, "/") + "/models" - - client := &http.Client{Timeout: 15 * time.Second} - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("build request: %w", err) - } - if apiKey != "" { - req.Header.Set("Authorization", "Bearer "+apiKey) - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - var result modelsAPIResponse - if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 { - return result.Data, nil - } - - var arr []modelEntry - if err := json.Unmarshal(body, &arr); err == nil { - return arr, nil - } - - return nil, fmt.Errorf( - "decode response: unrecognized shape: %s", - strings.TrimSpace(string(body[:min(len(body), 256)])), - ) -} diff --git a/cmd/picoclaw-launcher-tui/ui/schemes.go b/cmd/picoclaw-launcher-tui/ui/schemes.go deleted file mode 100644 index e38d7fa86..000000000 --- a/cmd/picoclaw-launcher-tui/ui/schemes.go +++ /dev/null @@ -1,252 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -func (a *App) newSchemesPage() tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false) - table.SetBorder(true). - SetTitle(" [#00f0ff::b] PROVIDER SCHEMES "). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - rowToIdx := func(row int) int { return row / 2 } - - selectedSchemeName := func() string { - row, _ := table.GetSelection() - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - if idx >= 0 && idx < len(schemes) { - return schemes[idx].Name - } - return "" - } - - rebuild := func() { - selName := selectedSchemeName() - table.Clear() - schemes := a.cfg.Provider.Schemes - for i, s := range schemes { - nameRow := i * 2 - detailRow := nameRow + 1 - - table.SetCell(nameRow, 0, - tview.NewTableCell(" "+s.Name). - SetTextColor(tcell.NewHexColor(0xe0e0e0)). - SetExpansion(1). - SetSelectable(true), - ) - - users := a.cfg.Provider.UsersForScheme(s.Name) - n := len(users) - m := 0 - for _, u := range users { - if models := a.cachedModels(s.Name, u.Name); len(models) > 0 { - m++ - } - } - table.SetCell(detailRow, 0, - tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)). - SetTextColor(tcell.NewHexColor(0x808080)). - SetExpansion(1). - SetSelectable(false), - ) - table.SetCell(detailRow, 1, - tview.NewTableCell("[#00f0ff]"+s.Type+" "). - SetAlign(tview.AlignRight). - SetSelectable(false), - ) - } - if selName != "" { - for i, s := range schemes { - if s.Name == selName { - table.Select(i*2, 0) - return - } - } - } - if table.GetRowCount() > 0 { - table.Select(0, 0) - } - } - rebuild() - - a.refreshModelCache(rebuild) - a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) } - - table.SetSelectedFunc(func(row, _ int) { - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - if idx < 0 || idx >= len(schemes) { - return - } - name := schemes[idx].Name - a.navigateTo("users", a.newUsersPage(name)) - }) - - table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - row, _ := table.GetSelection() - idx := rowToIdx(row) - schemes := a.cfg.Provider.Schemes - switch event.Rune() { - case 'a': - a.showSchemeForm(nil, func(s tuicfg.Scheme) { - a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - case 'e': - if idx < 0 || idx >= len(schemes) { - return nil - } - origName := schemes[idx].Name - orig := schemes[idx] - a.showSchemeForm(&orig, func(s tuicfg.Scheme) { - current := a.cfg.Provider.Schemes - for i, sc := range current { - if sc.Name == origName { - a.cfg.Provider.Schemes[i] = s - break - } - } - a.save() - a.refreshModelCache(func() { - rebuild() - for i, sc := range a.cfg.Provider.Schemes { - if sc.Name == s.Name { - table.Select(i*2, 0) - break - } - } - }) - }) - return nil - case 'd': - if idx < 0 || idx >= len(schemes) { - return nil - } - name := schemes[idx].Name - a.confirmDelete(fmt.Sprintf("scheme %q", name), func() { - current := a.cfg.Provider.Schemes - newSchemes := make([]tuicfg.Scheme, 0, len(current)) - for _, sc := range current { - if sc.Name != name { - newSchemes = append(newSchemes, sc) - } - } - a.cfg.Provider.Schemes = newSchemes - - existing := a.cfg.Provider.Users - filtered := make([]tuicfg.User, 0, len(existing)) - for _, u := range existing { - if u.Scheme != name { - filtered = append(filtered, u) - } - } - a.cfg.Provider.Users = filtered - - a.save() - a.refreshModelCache(rebuild) - }) - return nil - } - return event - }) - - return a.buildShell( - "schemes", - table, - " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ", - ) -} - -func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) { - name := "" - baseURL := "" - schemeType := "openai-compatible" - title := " ADD SCHEME " - - if existing != nil { - name = existing.Name - baseURL = existing.BaseURL - schemeType = existing.Type - title = " EDIT SCHEME " - } - - typeOptions := []string{"openai-compatible", "anthropic"} - typeIdx := 0 - for i, t := range typeOptions { - if t == schemeType { - typeIdx = i - break - } - } - - form := tview.NewForm() - - form. - AddInputField("Name", name, 20, nil, func(text string) { name = text }). - AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }). - AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }). - AddButton("SAVE", func() { - if name == "" { - a.showError("Name is required") - return - } - if baseURL == "" { - a.showError("Base URL is required") - return - } - if existing == nil { - for _, s := range a.cfg.Provider.Schemes { - if s.Name == name { - a.showError(fmt.Sprintf("Scheme name %q already exists", name)) - return - } - } - } - a.hideModal("scheme-form") - onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType}) - }). - AddButton("CANCEL", func() { - a.hideModal("scheme-form") - }) - - form.SetBorder(true). - SetTitle(" [::b]" + title + " "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("scheme-form") - return nil - } - return event - }) - - a.showModal("scheme-form", centeredForm(form, 4, 12)) -} diff --git a/cmd/picoclaw-launcher-tui/ui/users.go b/cmd/picoclaw-launcher-tui/ui/users.go deleted file mode 100644 index b00fc8982..000000000 --- a/cmd/picoclaw-launcher-tui/ui/users.go +++ /dev/null @@ -1,261 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// License: MIT -// -// Copyright (c) 2026 PicoClaw contributors - -package ui - -import ( - "fmt" - - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - - tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config" -) - -func (a *App) newUsersPage(schemeName string) tview.Primitive { - table := tview.NewTable(). - SetBorders(false). - SetSelectable(true, false) - table.SetBorder(true). - SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)). - SetTitleColor(tcell.NewHexColor(0x00f0ff)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - table.SetSelectedStyle( - tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)), - ) - table.SetBackgroundColor(tcell.NewHexColor(0x050510)) - - visibleUsers := func() []tuicfg.User { - var out []tuicfg.User - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName { - out = append(out, u) - } - } - return out - } - - findUserGlobalIdx := func(userName string) int { - for i, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == userName { - return i - } - } - return -1 - } - - rowToVisIdx := func(row int) int { return row / 2 } - - selectedUserName := func() string { - row, _ := table.GetSelection() - users := visibleUsers() - visIdx := rowToVisIdx(row) - if visIdx >= 0 && visIdx < len(users) { - return users[visIdx].Name - } - return "" - } - - rebuild := func() { - selName := selectedUserName() - table.Clear() - users := visibleUsers() - for i, u := range users { - nameRow := i * 2 - detailRow := nameRow + 1 - - table.SetCell(nameRow, 0, - tview.NewTableCell(" "+u.Name). - SetTextColor(tcell.NewHexColor(0xe0e0e0)). - SetExpansion(1). - SetSelectable(true), - ) - table.SetCell(nameRow, 1, - tview.NewTableCell(""). - SetSelectable(false), - ) - - models := a.cachedModels(schemeName, u.Name) - var detailText string - if len(models) > 0 { - detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models)) - } else { - detailText = " [#ff2a2a]Inactive / No Access[-]" - } - table.SetCell(detailRow, 0, - tview.NewTableCell(detailText). - SetTextColor(tcell.NewHexColor(0x808080)). - SetExpansion(1). - SetSelectable(false), - ) - table.SetCell(detailRow, 1, - tview.NewTableCell("[#00f0ff]"+u.Type+" "). - SetAlign(tview.AlignRight). - SetSelectable(false), - ) - } - if selName != "" { - for i, u := range users { - if u.Name == selName { - table.Select(i*2, 0) - return - } - } - } - if table.GetRowCount() > 0 { - table.Select(0, 0) - } - } - rebuild() - - a.refreshModelCache(rebuild) - a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) } - - table.SetSelectedFunc(func(row, _ int) { - visIdx := rowToVisIdx(row) - users := visibleUsers() - if visIdx < 0 || visIdx >= len(users) { - return - } - uName := users[visIdx].Name - scheme := a.cfg.Provider.SchemeByName(schemeName) - if scheme == nil { - a.showError(fmt.Sprintf("Scheme %q not found", schemeName)) - return - } - a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL)) - }) - - table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - row, _ := table.GetSelection() - visIdx := rowToVisIdx(row) - users := visibleUsers() - switch event.Rune() { - case 'a': - a.showUserForm(schemeName, nil, func(u tuicfg.User) { - a.cfg.Provider.Users = append(a.cfg.Provider.Users, u) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - case 'e': - if visIdx < 0 || visIdx >= len(users) { - return nil - } - origName := users[visIdx].Name - orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)] - a.showUserForm(schemeName, &orig, func(u tuicfg.User) { - cfgIdx := findUserGlobalIdx(origName) - if cfgIdx < 0 { - a.showError(fmt.Sprintf("User %q no longer exists", origName)) - return - } - a.cfg.Provider.Users[cfgIdx] = u - a.save() - a.refreshModelCache(func() { - rebuild() - for i, usr := range visibleUsers() { - if usr.Name == u.Name { - table.Select(i*2, 0) - break - } - } - }) - }) - return nil - case 'd': - if visIdx < 0 || visIdx >= len(users) { - return nil - } - uName := users[visIdx].Name - a.confirmDelete(fmt.Sprintf("user %q", uName), func() { - cfgIdx := findUserGlobalIdx(uName) - if cfgIdx < 0 { - return - } - all := a.cfg.Provider.Users - a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...) - a.save() - a.refreshModelCache(rebuild) - }) - return nil - } - return event - }) - - return a.buildShell( - "users", - table, - " [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ", - ) -} - -func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) { - name := "" - userType := "key" - key := "" - title := " ADD USER " - - if existing != nil { - name = existing.Name - userType = existing.Type - key = existing.Key - title = " EDIT USER " - } - - typeOptions := []string{"key", "OAuth"} - typeIdx := 0 - for i, t := range typeOptions { - if t == userType { - typeIdx = i - break - } - } - - form := tview.NewForm() - form. - AddInputField("Name", name, 20, nil, func(text string) { name = text }). - AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }). - AddPasswordField("Key", key, 28, '*', func(text string) { key = text }). - AddButton("SAVE", func() { - if name == "" { - a.showError("Name is required") - return - } - if existing == nil { - for _, u := range a.cfg.Provider.Users { - if u.Scheme == schemeName && u.Name == name { - a.showError(fmt.Sprintf("User name %q already exists for this scheme", name)) - return - } - } - } - a.hideModal("user-form") - onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key}) - }). - AddButton("CANCEL", func() { - a.hideModal("user-form") - }) - - form.SetBorder(true). - SetTitle(" [::b]" + title + " "). - SetTitleColor(tcell.NewHexColor(0x39ff14)). - SetBorderColor(tcell.NewHexColor(0x00f0ff)) - form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) - form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510)) - form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff)) - form.SetLabelColor(tcell.NewHexColor(0xe0e0e0)) - form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff)) - form.SetButtonTextColor(tcell.NewHexColor(0xffffff)) - form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - a.hideModal("user-form") - return nil - } - return event - }) - - a.showModal("user-form", centeredForm(form, 4, 13)) -} diff --git a/cmd/picoclaw/internal/cliui/mcp_show.go b/cmd/picoclaw/internal/cliui/mcp_show.go new file mode 100644 index 000000000..5d5af1e75 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/mcp_show.go @@ -0,0 +1,384 @@ +package cliui + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// MCPShowServer holds the server metadata for PrintMCPShow. +type MCPShowServer struct { + Name string + Type string + Target string + Enabled bool + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global + EnvKeys []string // sorted env var names (values intentionally omitted) + EnvFile string + Headers []string // sorted header names +} + +// MCPShowTool holds one tool's info for PrintMCPShow. +type MCPShowTool struct { + Name string + Description string + Parameters []MCPShowParam +} + +// MCPShowParam is one parameter entry. +type MCPShowParam struct { + Name string + Type string + Description string + Required bool +} + +// PrintMCPShow renders the mcp show output (plain or fancy). +// w is where the output is written; pass cmd.OutOrStdout() from cobra commands. +func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + if !UseFancyLayout() { + printMCPShowPlain(w, server, tools, disabled) + return + } + printMCPShowFancy(w, server, tools, disabled) +} + +// ── plain (narrow / non-TTY) ──────────────────────────────────────────────── + +func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + fmt.Fprintf(w, "Server: %s\n", server.Name) + fmt.Fprintf(w, "Type: %s\n", server.Type) + fmt.Fprintf(w, "Target: %s\n", server.Target) + fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled)) + deferredLabel := boolWord(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredLabel += " (default)" + } + fmt.Fprintf(w, "Deferred: %s\n", deferredLabel) + if len(server.EnvKeys) > 0 { + fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", ")) + } + if server.EnvFile != "" { + fmt.Fprintf(w, "Env file: %s\n", server.EnvFile) + } + if len(server.Headers) > 0 { + fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", ")) + } + fmt.Fprintln(w) + + if disabled { + fmt.Fprintln(w, "Server is disabled; skipping tool discovery.") + return + } + if len(tools) == 0 { + fmt.Fprintln(w, "No tools exposed by this server.") + return + } + + fmt.Fprintf(w, "Tools (%d):\n", len(tools)) + for _, tool := range tools { + fmt.Fprintf(w, " %s\n", tool.Name) + if tool.Description != "" { + fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120)) + } + if len(tool.Parameters) == 0 { + fmt.Fprintln(w, " Parameters: none") + continue + } + for _, p := range tool.Parameters { + line := fmt.Sprintf(" - %s", p.Name) + if p.Type != "" { + line += fmt.Sprintf(" (%s", p.Type) + if p.Required { + line += ", required" + } + line += ")" + } else if p.Required { + line += " (required)" + } + if p.Description != "" { + line += ": " + truncateDescription(p.Description, 80) + } + fmt.Fprintln(w, line) + } + } +} + +// ── fancy (wide TTY) ──────────────────────────────────────────────────────── + +var ( + mcpToolNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) + } + mcpParamNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentRed).Bold(true) + } + mcpTagStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + } + mcpRequiredStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + } + mcpOptionalStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + } + mcpDescStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + } +) + +func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + // ── server header ── + b.WriteString(titleBarStyle().Render("⬡ " + server.Name)) + b.WriteString("\n\n") + + keyW := 10 + writeKV := func(key, val string) { + k := kvKeyStyle().Width(keyW).Render(key) + b.WriteString(k + " " + val + "\n") + } + + writeKV("Type", server.Type) + writeKV("Target", server.Target) + writeKV("Enabled", coloredBool(server.Enabled)) + deferredVal := coloredBool(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredVal += " " + mcpTagStyle().Render("(default)") + } + writeKV("Deferred", deferredVal) + if len(server.EnvKeys) > 0 { + writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", "))) + } + if server.EnvFile != "" { + writeKV("Env file", mutedStyle().Render(server.EnvFile)) + } + if len(server.Headers) > 0 { + writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", "))) + } + + if disabled { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + if len(tools) == 0 { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("No tools exposed by this server.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + // ── tools section ── + b.WriteString("\n") + b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools)))) + b.WriteString("\n") + + contentW := inner - 4 // account for box padding + for i, tool := range tools { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + // Tool name + index badge + badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools))) + b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n") + + // Description (wrapped to content width) + if tool.Description != "" { + desc := truncateDescription(tool.Description, 160) + b.WriteString(" " + mcpDescStyle().Render(desc) + "\n") + } + + // Parameters + if len(tool.Parameters) == 0 { + b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n") + continue + } + + b.WriteString("\n") + for _, p := range tool.Parameters { + // name + pName := mcpParamNameStyle().Render(p.Name) + + // type tag + typeTag := "" + if p.Type != "" { + typeTag = " " + mcpTagStyle().Render("<"+p.Type+">") + } + + // required / optional badge + var reqBadge string + if p.Required { + reqBadge = " " + mcpRequiredStyle().Render("required") + } else { + reqBadge = " " + mcpOptionalStyle().Render("optional") + } + + b.WriteString(" " + pName + typeTag + reqBadge + "\n") + + if p.Description != "" { + desc := truncateDescription(p.Description, 120) + b.WriteString(" " + mutedStyle().Render(desc) + "\n") + } + } + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +// ── mcp list ──────────────────────────────────────────────────────────────── + +// MCPListRow is one row in the mcp list output. +type MCPListRow struct { + Name string + Type string + Target string + Status string // "enabled", "disabled", "ok (N tools)", "error" + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global +} + +// PrintMCPList renders the mcp list output (plain or fancy). +func PrintMCPList(w io.Writer, rows []MCPListRow) { + if !UseFancyLayout() { + printMCPListPlain(w, rows) + return + } + printMCPListFancy(w, rows) +} + +func printMCPListPlain(w io.Writer, rows []MCPListRow) { + headers := []string{"Name", "Type", "Command", "Status", "Deferred"} + tableRows := make([][]string, len(rows)) + for i, r := range rows { + deferred := boolWord(r.EffectiveDeferred) + if !r.DeferredExplicit { + deferred += " (default)" + } + tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred} + } + // reuse the ASCII table renderer already in helpers.go via the caller + // (list.go still uses renderTable for the plain path) + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range tableRows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + border := func() { + fmt.Fprint(w, "+") + for _, width := range widths { + fmt.Fprint(w, strings.Repeat("-", width+2)+"+") + } + fmt.Fprintln(w) + } + writeRow := func(row []string) { + fmt.Fprint(w, "|") + for i, cell := range row { + fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell))) + } + fmt.Fprintln(w) + } + border() + writeRow(headers) + border() + for _, row := range tableRows { + writeRow(row) + } + border() +} + +func printMCPListFancy(w io.Writer, rows []MCPListRow) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + title := fmt.Sprintf("MCP Servers (%d)", len(rows)) + b.WriteString(titleBarStyle().Render(title)) + b.WriteString("\n") + + contentW := inner - 4 + for i, row := range rows { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + statusBadge := mcpListStatusStyle(row.Status).Render(row.Status) + var deferredBadge string + if row.EffectiveDeferred { + if row.DeferredExplicit { + deferredBadge = " " + mcpTagStyle().Render("deferred") + } else { + deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)") + } + } + b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n") + b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n") + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +func mcpListStatusStyle(status string) lipgloss.Style { + switch { + case status == "enabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "disabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + case strings.HasPrefix(status, "ok"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "error": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + default: + return lipgloss.NewStyle() + } +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +func boolWord(v bool) string { + if v { + return "yes" + } + return "no" +} + +func coloredBool(v bool) string { + if v { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes") + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no") +} + +// truncateDescription strips newlines, collapses whitespace, and caps length. +func truncateDescription(s string, maxLen int) string { + // collapse newlines and repeated spaces into a single space + s = strings.Join(strings.Fields(s), " ") + if len(s) <= maxLen { + return s + } + // cut at last space before maxLen + cut := s[:maxLen] + if idx := strings.LastIndex(cut, " "); idx > maxLen/2 { + cut = cut[:idx] + } + return cut + "…" +} diff --git a/cmd/picoclaw/internal/mcp/add.go b/cmd/picoclaw/internal/mcp/add.go new file mode 100644 index 000000000..8ad68571f --- /dev/null +++ b/cmd/picoclaw/internal/mcp/add.go @@ -0,0 +1,249 @@ +package mcp + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type addOptions struct { + Env []string + EnvFile string + Headers []string + Transport string + Force bool + Deferred *bool // nil = not set, true = deferred, false = not deferred +} + +func newAddCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add [flags] [args...]", + Short: "Add or update an MCP server", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts, name, target, targetArgs, showHelp, err := parseAddArgs(args) + if showHelp { + return cmd.Help() + } + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + if cfg.Tools.MCP.Servers == nil { + cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig) + } + + if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force { + var overwrite bool + + overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name) + if err != nil { + return fmt.Errorf("failed to confirm overwrite: %w", err) + } + if !overwrite { + return fmt.Errorf("aborted: MCP server %q already exists", name) + } + } + + server, err := buildServerConfig(target, targetArgs, opts) + if err != nil { + return err + } + + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Servers[name] = server + + if err := saveValidatedConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name) + return nil + }, + } + + flags := cmd.Flags() + flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)") + flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)") + flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)") + flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse") + flags.BoolP("force", "f", false, "Overwrite an existing server without prompting") + flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)") + flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)") + + return cmd +} + +func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) { + opts := addOptions{Transport: "stdio"} + var positional []string + serverArgs := make([]string, 0) + explicitCommand := make([]string, 0) + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch { + case arg == "--help" || arg == "-h": + return addOptions{}, "", "", nil, true, nil + case arg == "--": + if i+1 < len(args) { + explicitCommand = append(explicitCommand, args[i+1:]...) + } + i = len(args) + case arg == "--force" || arg == "-f": + opts.Force = true + case arg == "--deferred": + t := true + opts.Deferred = &t + case arg == "--no-deferred": + f := false + opts.Deferred = &f + case arg == "--transport" || arg == "-t": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Transport = args[i] + case strings.HasPrefix(arg, "--transport="): + opts.Transport = strings.TrimPrefix(arg, "--transport=") + case arg == "--env" || arg == "-e": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Env = append(opts.Env, args[i]) + case arg == "--env-file": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.EnvFile = args[i] + case strings.HasPrefix(arg, "--env="): + opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env=")) + case strings.HasPrefix(arg, "--env-file="): + opts.EnvFile = strings.TrimPrefix(arg, "--env-file=") + case arg == "--header" || arg == "-H": + if i+1 >= len(args) { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg) + } + i++ + opts.Headers = append(opts.Headers, args[i]) + case strings.HasPrefix(arg, "--header="): + opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header=")) + case strings.HasPrefix(arg, "-") && len(positional) >= 2: + serverArgs = append(serverArgs, args[i:]...) + i = len(args) + default: + positional = append(positional, arg) + } + } + + if len(explicitCommand) > 0 { + if len(positional) != 1 { + return addOptions{}, "", "", nil, false, fmt.Errorf( + "usage: picoclaw mcp add [flags] [args...] or picoclaw mcp add [flags] -- [args...]", + ) + } + if len(explicitCommand) == 0 { + return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --") + } + return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil + } + + if len(positional) < 2 { + return addOptions{}, "", "", nil, false, fmt.Errorf( + "usage: picoclaw mcp add [flags] [args...] or picoclaw mcp add [flags] -- [args...]", + ) + } + + targetArgs := make([]string, 0, len(positional)-2+len(serverArgs)) + targetArgs = append(targetArgs, positional[2:]...) + targetArgs = append(targetArgs, serverArgs...) + + return opts, positional[0], positional[1], targetArgs, false, nil +} + +func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) { + transport := strings.ToLower(strings.TrimSpace(opts.Transport)) + if transport == "" { + transport = "stdio" + } + switch transport { + case "stdio", "http", "sse": + default: + return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport) + } + + env, err := parseEnvAssignments(opts.Env) + if err != nil { + return config.MCPServerConfig{}, err + } + headers, err := parseHeaderAssignments(opts.Headers) + if err != nil { + return config.MCPServerConfig{}, err + } + + server := config.MCPServerConfig{ + Enabled: true, + Type: transport, + Deferred: opts.Deferred, + } + + switch transport { + case "http", "sse": + if len(env) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport") + } + if strings.TrimSpace(opts.EnvFile) != "" { + return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport") + } + if len(args) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport) + } + parsedURL, err := url.ParseRequestURI(target) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target) + } + server.URL = target + server.Headers = headers + return server, nil + } + + if len(headers) > 0 { + return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport") + } + + if looksLikeRemoteURL(target) { + return config.MCPServerConfig{}, fmt.Errorf( + "target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse", + target, + transport, + ) + } + + command := target + commandArgs := append([]string(nil), args...) + + if err := validateLocalCommandPath(target); err != nil { + return config.MCPServerConfig{}, err + } + if isLocalCommandPath(command) { + command = expandHomePath(command) + } + + server.Command = command + server.Args = commandArgs + server.Env = env + server.EnvFile = strings.TrimSpace(opts.EnvFile) + + return server, nil +} diff --git a/cmd/picoclaw/internal/mcp/command.go b/cmd/picoclaw/internal/mcp/command.go new file mode 100644 index 000000000..d6e21181a --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command.go @@ -0,0 +1,25 @@ +package mcp + +import "github.com/spf13/cobra" + +func NewMCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage MCP server configuration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + newAddCommand(), + newRemoveCommand(), + newListCommand(), + newEditCommand(), + newTestCommand(), + newShowCommand(), + ) + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/command_test.go b/cmd/picoclaw/internal/mcp/command_test.go new file mode 100644 index 000000000..be1c9763e --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command_test.go @@ -0,0 +1,619 @@ +package mcp + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewMCPCommand(t *testing.T) { + cmd := NewMCPCommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "mcp", cmd.Use) + assert.Equal(t, "Manage MCP server configuration", cmd.Short) + assert.True(t, cmd.HasSubCommands()) + + allowedCommands := []string{ + "add", + "remove", + "list", + "edit", + "test", + "show", + } + + subcommands := cmd.Commands() + assert.Len(t, subcommands, len(allowedCommands)) + + for _, subcmd := range subcommands { + found := slices.Contains(allowedCommands, subcmd.Name()) + assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) + assert.False(t, subcmd.Hidden) + } +} + +func TestMCPAddAddsGenericStdioServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{ + "add", + "sqlite", + "npx", + "-y", + "@modelcontextprotocol/server-sqlite", + "--db", + "./mydb.db", + }, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "sqlite" saved`) + + cfg := readMCPConfig(t, configPath) + require.True(t, cfg.Tools.MCP.Enabled) + + server, ok := cfg.Tools.MCP.Servers["sqlite"] + require.True(t, ok) + assert.True(t, server.Enabled) + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args) +} + +func TestMCPAddSupportsHeadersAfterURL(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "apify", + "https://mcp.apify.com/", + "-t", + "http", + "--header", + "Authorization: Bearer OMITTED", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["apify"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.apify.com/", server.URL) + assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers) +} + +func TestMCPAddSupportsTransportBeforeName(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "sse", + "fiscal-ai", + "https://api.fiscal.ai/mcp/sse", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["fiscal-ai"] + assert.Equal(t, "sse", server.Type) + assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL) +} + +func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "stdio", + "--env", + "AIRTABLE_API_KEY=YOUR_KEY", + "airtable", + "--", + "npx", + "-y", + "airtable-mcp-server", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["airtable"] + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args) + assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env) +} + +func TestMCPAddSupportsEnvFileForStdio(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--env-file", + ".env.mcp", + "filesystem", + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["filesystem"] + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args) + assert.Equal(t, ".env.mcp", server.EnvFile) +} + +func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) { + setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "http", + "--env-file", + ".env.mcp", + "context7", + "https://mcp.context7.com/mcp", + }, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport") +} + +func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) { + setupMCPConfigEnv(t) + + tmpDir := t.TempDir() + localCmd := filepath.Join(tmpDir, "server.sh") + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644)) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local", localCmd}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "not executable") +} + +func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + localCmd := filepath.Join(homeDir, "bin", "my-mcp") + require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755)) + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + + tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["local-home"] + assert.Equal(t, localCmd, server.Command) +} + +func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) { + setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `looks like a remote MCP URL`) + assert.Contains(t, err.Error(), `Use --transport http or --transport sse`) +} + +func TestMCPAddOverwritePromptDecline(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n") + require.Error(t, err) + assert.Contains(t, output, `Overwrite? [y/N]:`) + assert.Contains(t, err.Error(), "aborted") + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddOverwriteWithConfirmation(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddHTTPServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "context7", + "--transport", + "http", + "https://mcp.context7.com/mcp", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["context7"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.context7.com/mcp", server.URL) + assert.Empty(t, server.Command) +} + +func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" removed`) + + cfg := readMCPConfig(t, configPath) + assert.False(t, cfg.Tools.MCP.Enabled) + assert.Empty(t, cfg.Tools.MCP.Servers) +} + +func TestMCPListPrintsTable(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "context7": { + Enabled: true, + Type: "http", + URL: "https://mcp.context7.com/mcp", + }, + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list"}, "") + require.NoError(t, err) + assert.Contains(t, output, "| Name") + assert.Contains(t, output, "context7") + assert.Contains(t, output, "filesystem") + assert.Contains(t, output, "https://mcp.context7.com/mcp") + assert.Contains(t, output, "disabled") +} + +func TestMCPListWithStatusUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + assert.Equal(t, "npx", server.Command) + return probeResult{ToolCount: 3}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list", "--status"}, "") + require.NoError(t, err) + assert.Contains(t, output, "ok (3 tools)") +} + +func TestMCPEditUsesEditor(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + originalEditor := editorCommand + defer func() { editorCommand = originalEditor }() + + var gotName string + var gotArgs []string + editorCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return exec.Command("sh", "-c", "exit 0") + } + + t.Setenv("EDITOR", `dummy-editor --wait`) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.NoError(t, err) + + assert.Equal(t, "dummy-editor", gotName) + assert.Equal(t, []string{"--wait", configPath}, gotArgs) + _, statErr := os.Stat(configPath) + assert.NoError(t, statErr) +} + +func TestMCPEditRequiresEditor(t *testing.T) { + setupMCPConfigEnv(t) + t.Setenv("EDITOR", "") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "$EDITOR is not set") +} + +func TestMCPTestUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + return probeResult{ToolCount: 2}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"test", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`) +} + +func TestMCPAddDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.True(t, *server.Deferred) +} + +func TestMCPAddNoDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.False(t, *server.Deferred) +} + +func TestMCPAddNoDeferredByDefault(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + assert.Nil(t, server.Deferred) +} + +func TestMCPShowNotFound(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, nil) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"show", "missing"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `"missing" not found`) +} + +func TestMCPShowDisabledServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "disabled") +} + +func TestMCPShowUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + original := serverShowProbe + defer func() { serverShowProbe = original }() + serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) { + assert.Equal(t, "myserver", name) + return []toolDetail{ + { + Name: "read_file", + Description: "Read a file from the filesystem", + Parameters: []paramDetail{ + {Name: "path", Type: "string", Description: "File path", Required: true}, + {Name: "encoding", Type: "string", Description: "Character encoding", Required: false}, + }, + }, + { + Name: "list_dir", + Description: "List directory contents", + Parameters: nil, + }, + }, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "read_file") + assert.Contains(t, output, "Read a file from the filesystem") + assert.Contains(t, output, "path") + assert.Contains(t, output, "string") + assert.Contains(t, output, "required") + assert.Contains(t, output, "list_dir") + assert.Contains(t, output, "none") +} + +func setupMCPConfigEnv(t *testing.T) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv(config.EnvConfig, configPath) + t.Setenv(config.EnvHome, filepath.Dir(configPath)) + return configPath +} + +func writeMCPConfig(t *testing.T, path string, cfg *config.Config) { + t.Helper() + + if cfg == nil { + cfg = config.DefaultConfig() + } + + require.NoError(t, config.SaveConfig(path, cfg)) +} + +func readMCPConfig(t *testing.T, path string) *config.Config { + t.Helper() + + cfg, err := config.LoadConfig(path) + require.NoError(t, err) + return cfg +} + +func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.SetArgs(args) + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetIn(strings.NewReader(stdin)) + + err := cmd.Execute() + return stdout.String() + stderr.String(), err +} diff --git a/cmd/picoclaw/internal/mcp/edit.go b/cmd/picoclaw/internal/mcp/edit.go new file mode 100644 index 000000000..06dcb6aef --- /dev/null +++ b/cmd/picoclaw/internal/mcp/edit.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "go.mau.fi/util/shlex" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" +) + +func newEditCommand() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open the PicoClaw config in $EDITOR", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + return fmt.Errorf("$EDITOR is not set") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + if err = saveValidatedConfig(cfg); err != nil { + return err + } + + editorArgs, err := shlex.Split(editor) + if err != nil { + return fmt.Errorf("failed to parse $EDITOR: %w", err) + } + if len(editorArgs) == 0 { + return fmt.Errorf("$EDITOR is empty") + } + + editorArgs = append(editorArgs, internal.GetConfigPath()) + process := editorCommand(editorArgs[0], editorArgs[1:]...) + process.Stdin = cmd.InOrStdin() + process.Stdout = cmd.OutOrStdout() + process.Stderr = cmd.ErrOrStderr() + + if err := process.Run(); err != nil { + return fmt.Errorf("failed to start editor: %w", err) + } + + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/helpers.go b/cmd/picoclaw/internal/mcp/helpers.go new file mode 100644 index 000000000..0fb0b245c --- /dev/null +++ b/cmd/picoclaw/internal/mcp/helpers.go @@ -0,0 +1,359 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type probeResult struct { + ToolCount int +} + +var ( + editorCommand = exec.Command + serverProbe = defaultServerProbe + + mcpConfigSchemaOnce sync.Once + mcpConfigSchema *jsonschema.Resolved + errMcpConfigSchema error +) + +const mcpConfigSchemaJSON = `{ + "type": "object", + "properties": { + "tools": { + "type": "object", + "properties": { + "mcp": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "discovery": { "type": "object", "additionalProperties": true }, + "max_inline_text_chars": { "type": "integer" }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "deferred": { "type": "boolean" }, + "command": { "type": "string" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "env_file": { "type": "string" }, + "type": { + "type": "string", + "enum": ["stdio", "http", "sse"] + }, + "url": { "type": "string" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["enabled"], + "anyOf": [ + { "required": ["command"] }, + { "required": ["url"] } + ], + "additionalProperties": false + } + } + }, + "required": ["enabled"], + "additionalProperties": true + } + }, + "required": ["mcp"], + "additionalProperties": true + } + }, + "required": ["tools"], + "additionalProperties": true +}` + +func loadConfig() (*config.Config, error) { + cfg, err := config.LoadConfig(internal.GetConfigPath()) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return cfg, nil +} + +func saveValidatedConfig(cfg *config.Config) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + + data, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to serialize config: %w", err) + } + + if err := validateConfigDocument(data); err != nil { + return err + } + + if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + return nil +} + +func validateConfigDocument(data []byte) error { + var instance map[string]any + if err := json.Unmarshal(data, &instance); err != nil { + return fmt.Errorf("failed to decode serialized config: %w", err) + } + + schema, err := loadMCPConfigSchema() + if err != nil { + return fmt.Errorf("failed to load MCP config schema: %w", err) + } + + if err := schema.Validate(instance); err != nil { + return fmt.Errorf("config validation failed: %w", err) + } + + return nil +} + +func loadMCPConfigSchema() (*jsonschema.Resolved, error) { + mcpConfigSchemaOnce.Do(func() { + var schema jsonschema.Schema + if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil { + errMcpConfigSchema = err + return + } + mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil) + }) + + return mcpConfigSchema, errMcpConfigSchema +} + +func inferTransportType(server config.MCPServerConfig) string { + switch server.Type { + case "stdio", "http", "sse": + return server.Type + } + if server.URL != "" { + return "sse" + } + if server.Command != "" { + return "stdio" + } + return "unknown" +} + +func renderServerTarget(server config.MCPServerConfig) string { + transport := inferTransportType(server) + if transport == "http" || transport == "sse" { + if server.URL == "" { + return "" + } + return server.URL + } + + parts := append([]string{server.Command}, server.Args...) + rendered := strings.TrimSpace(strings.Join(parts, " ")) + if rendered == "" { + return "" + } + return rendered +} + +func sortedServerNames(servers map[string]config.MCPServerConfig) []string { + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func parseEnvAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + env := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, "=") + if !found { + return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry) + } + env[key] = value + } + + return env, nil +} + +func parseHeaderAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + headers := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, ":") + if !found { + key, value, found = strings.Cut(entry, "=") + } + if !found { + return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry) + } + headers[key] = value + } + + return headers, nil +} + +func looksLikeRemoteURL(target string) bool { + parsedURL, err := url.ParseRequestURI(target) + if err != nil { + return false + } + if parsedURL.Host == "" { + return false + } + switch strings.ToLower(parsedURL.Scheme) { + case "http", "https": + return true + default: + return false + } +} + +func isLocalCommandPath(command string) bool { + if command == "" { + return false + } + if looksLikeRemoteURL(command) { + return false + } + return filepath.IsAbs(command) || + filepath.VolumeName(command) != "" || + strings.HasPrefix(command, "."+string(os.PathSeparator)) || + strings.HasPrefix(command, ".."+string(os.PathSeparator)) || + command == "." || + command == ".." || + strings.ContainsRune(command, os.PathSeparator) +} + +func expandHomePath(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") { + return filepath.Join(home, path[2:]) + } + return path +} + +func validateLocalCommandPath(command string) error { + if !isLocalCommandPath(command) { + return nil + } + + path := expandHomePath(command) + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("local command %q does not exist", command) + } + return fmt.Errorf("failed to stat local command %q: %w", command, err) + } + if info.IsDir() { + return fmt.Errorf("local command %q is a directory", command) + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + return fmt.Errorf("local command %q is not executable", command) + } + return nil +} + +func defaultServerProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) (probeResult, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return probeResult{}, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return probeResult{}, fmt.Errorf("server %q did not register a connection", name) + } + + return probeResult{ToolCount: len(conn.Tools)}, nil +} + +func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) { + if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil { + return false, err + } + + var answer string + if _, err := fmt.Fscanln(r, &answer); err != nil { + if errors.Is(err, io.EOF) { + return false, nil + } + return false, err + } + + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/picoclaw/internal/mcp/list.go b/cmd/picoclaw/internal/mcp/list.go new file mode 100644 index 000000000..f95fcf65d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/list.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" +) + +func newListCommand() *cobra.Command { + var ( + includeStatus bool + timeout time.Duration + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List configured MCP servers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + if len(cfg.Tools.MCP.Servers) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.") + return nil + } + + rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers)) + for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) { + server := cfg.Tools.MCP.Servers[name] + status := "disabled" + if server.Enabled { + status = "enabled" + } + + if includeStatus && server.Enabled { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath()) + cancel() + if probeErr != nil { + status = "error" + } else { + status = fmt.Sprintf("ok (%d tools)", result.ToolCount) + } + } + + effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + + rows = append(rows, cliui.MCPListRow{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Status: status, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + }) + } + + cliui.PrintMCPList(cmd.OutOrStdout(), rows) + return nil + }, + } + + cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status") + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check") + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/remove.go b/cmd/picoclaw/internal/mcp/remove.go new file mode 100644 index 000000000..d82af941d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/remove.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newRemoveCommand() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an MCP server from config", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + if _, exists := cfg.Tools.MCP.Servers[name]; !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + delete(cfg.Tools.MCP.Servers, name) + if len(cfg.Tools.MCP.Servers) == 0 { + cfg.Tools.MCP.Servers = nil + cfg.Tools.MCP.Enabled = false + } + + if err := saveValidatedConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name) + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/show.go b/cmd/picoclaw/internal/mcp/show.go new file mode 100644 index 000000000..65953c2da --- /dev/null +++ b/cmd/picoclaw/internal/mcp/show.go @@ -0,0 +1,237 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type toolDetail struct { + Name string + Description string + Parameters []paramDetail +} + +type paramDetail struct { + Name string + Type string + Description string + Required bool +} + +var serverShowProbe = defaultServerShowProbe + +func defaultServerShowProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) ([]toolDetail, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return nil, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return nil, fmt.Errorf("server %q did not register a connection", name) + } + + details := make([]toolDetail, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + details = append(details, toolDetail{ + Name: tool.Name, + Description: tool.Description, + Parameters: extractParameters(tool.InputSchema), + }) + } + return details, nil +} + +func extractParameters(schema any) []paramDetail { + schemaMap := normalizeSchema(schema) + properties, ok := schemaMap["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return nil + } + + required := make(map[string]struct{}) + switch raw := schemaMap["required"].(type) { + case []string: + for _, name := range raw { + required[name] = struct{}{} + } + case []any: + for _, value := range raw { + if name, ok := value.(string); ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]paramDetail, 0, len(names)) + for _, name := range names { + param := paramDetail{Name: name} + if propMap, ok := properties[name].(map[string]any); ok { + if typeName, ok := propMap["type"].(string); ok { + param.Type = strings.TrimSpace(typeName) + } + if desc, ok := propMap["description"].(string); ok { + param.Description = strings.TrimSpace(desc) + } + } + _, param.Required = required[name] + params = append(params, param) + } + return params +} + +func normalizeSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{} + } + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + var jsonData []byte + switch raw := schema.(type) { + case json.RawMessage: + jsonData = raw + case []byte: + jsonData = raw + default: + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{} + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{} + } + return result +} + +func newShowCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details and tools for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled) + + if !server.Enabled { + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to connect to MCP server %q: %w", name, err) + } + + tools := make([]cliui.MCPShowTool, 0, len(details)) + for _, d := range details { + params := make([]cliui.MCPShowParam, 0, len(d.Parameters)) + for _, p := range d.Parameters { + params = append(params, cliui.MCPShowParam{ + Name: p.Name, + Type: p.Type, + Description: p.Description, + Required: p.Required, + }) + } + tools = append(tools, cliui.MCPShowTool{ + Name: d.Name, + Description: d.Description, + Parameters: params, + }) + } + + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout") + + return cmd +} + +func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer { + effectiveDeferred := discoveryEnabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + info := cliui.MCPShowServer{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Enabled: server.Enabled, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + EnvFile: server.EnvFile, + } + if len(server.Env) > 0 { + keys := make([]string, 0, len(server.Env)) + for k := range server.Env { + keys = append(keys, k) + } + sort.Strings(keys) + info.EnvKeys = keys + } + if len(server.Headers) > 0 { + keys := make([]string, 0, len(server.Headers)) + for k := range server.Headers { + keys = append(keys, k) + } + sort.Strings(keys) + info.Headers = keys + } + return info +} diff --git a/cmd/picoclaw/internal/mcp/test.go b/cmd/picoclaw/internal/mcp/test.go new file mode 100644 index 000000000..101cfee65 --- /dev/null +++ b/cmd/picoclaw/internal/mcp/test.go @@ -0,0 +1,46 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +func newTestCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "test ", + Short: "Test connectivity for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + result, err := serverProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to reach MCP server %q: %w", name, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout") + + return cmd +} diff --git a/cmd/picoclaw/internal/model/add.go b/cmd/picoclaw/internal/model/add.go new file mode 100644 index 000000000..b3ebba340 --- /dev/null +++ b/cmd/picoclaw/internal/model/add.go @@ -0,0 +1,200 @@ +package model + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" +) + +const defaultAliasName = "custom-prefer" + +func newAddCommand() *cobra.Command { + var ( + apiBase string + apiKey string + modelID string + alias string + modelType string + ) + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a model from an OpenAI-compatible endpoint", + Long: `Add a model entry by querying an OpenAI-compatible endpoint exposing +GET /models, then setting it as the default model. + +If --model is omitted, the available models are listed and you can pick one +interactively. If --model is provided, the entry is written without contacting +the server. + +Sample interactive session (key shown masked): + + $ picoclaw model add \ + -b https://ark.cn-beijing.volces.com/api/v3 \ + -k 7dff****-****-****-****-********e829 + + 115 model(s) available: + 1) doubao-lite-128k-240428 (doubao-lite-128k) + 2) doubao-pro-128k-240515 (doubao-pro-128k) + ... + 48) deepseek-r1-250120 (deepseek-r1) + 78) kimi-k2-250711 (kimi-k2) + ... + 115) doubao-seed3d-2-0-260328 (doubao-seed3d-2-0) + Pick a model (number or id): 48 + ✓ Saved model 'custom-prefer' (deepseek-r1-250120) and set as default.`, + Example: ` picoclaw model add --api-base https://api.openai.com/v1 --api-key sk-... + picoclaw model add -b http://localhost:8000/v1 -k dummy -m my-model -n local`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runAdd(addOptions{ + apiBase: strings.TrimSpace(apiBase), + apiKey: strings.TrimSpace(apiKey), + modelID: strings.TrimSpace(modelID), + alias: strings.TrimSpace(alias), + modelType: strings.TrimSpace(modelType), + stdin: cmd.InOrStdin(), + stdout: cmd.OutOrStdout(), + }) + }, + } + + cmd.Flags().StringVarP(&apiBase, "api-base", "b", "", + "API base URL (required), e.g. https://api.openai.com/v1") + cmd.Flags().StringVarP(&apiKey, "api-key", "k", "", "API key (required)") + cmd.Flags().StringVarP(&modelID, "model", "m", "", + "Model id; when set, skips the interactive picker and the network call") + cmd.Flags().StringVarP(&alias, "name", "n", defaultAliasName, + "Local alias written to model_list and used as the default model name") + cmd.Flags().StringVar(&modelType, "type", "openai-compatible", + "Endpoint type (only 'openai-compatible' is supported today)") + _ = cmd.MarkFlagRequired("api-base") + _ = cmd.MarkFlagRequired("api-key") + + return cmd +} + +type addOptions struct { + apiBase string + apiKey string + modelID string + alias string + modelType string + stdin io.Reader + stdout io.Writer +} + +func runAdd(opt addOptions) error { + if opt.modelType != "" && opt.modelType != "openai-compatible" { + return fmt.Errorf("unsupported --type %q (only 'openai-compatible' is supported)", opt.modelType) + } + if opt.alias == "" { + opt.alias = defaultAliasName + } + + selected := opt.modelID + if selected == "" { + entries, err := fetchOpenAIModels(opt.apiBase, opt.apiKey) + if err != nil { + return fmt.Errorf("fetch models: %w", err) + } + if len(entries) == 0 { + return fmt.Errorf("no models returned by %s", opt.apiBase) + } + selected, err = pickModel(opt.stdin, opt.stdout, entries) + if err != nil { + return err + } + } + + return upsertModelDefault(opt.apiBase, opt.apiKey, opt.alias, selected, opt.stdout) +} + +func pickModel(stdin io.Reader, stdout io.Writer, entries []modelEntry) (string, error) { + fmt.Fprintf(stdout, "\n%d model(s) available:\n", len(entries)) + for i, m := range entries { + line := m.ID + if m.Name != "" && m.Name != m.ID { + line = fmt.Sprintf("%s (%s)", m.ID, m.Name) + } + fmt.Fprintf(stdout, " %3d) %s\n", i+1, line) + } + + scanner := bufio.NewScanner(stdin) + for { + fmt.Fprint(stdout, "Pick a model (number or id): ") + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read input: %w", err) + } + return "", fmt.Errorf("no selection provided") + } + text := strings.TrimSpace(scanner.Text()) + if text == "" { + continue + } + if idx, err := strconv.Atoi(text); err == nil { + if idx < 1 || idx > len(entries) { + fmt.Fprintf(stdout, "Out of range. Enter 1-%d.\n", len(entries)) + continue + } + return entries[idx-1].ID, nil + } + for _, m := range entries { + if m.ID == text { + return m.ID, nil + } + } + fmt.Fprintln(stdout, "Not a valid number or model id; try again.") + } +} + +func upsertModelDefault(apiBase, apiKey, alias, modelID string, stdout io.Writer) error { + configPath := internal.GetConfigPath() + cfg, err := config.LoadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + secureKeys := config.SimpleSecureStrings(apiKey) + + found := false + for _, m := range cfg.ModelList { + if m == nil { + continue + } + if m.ModelName == alias { + m.Model = modelID + m.APIBase = apiBase + m.APIKeys = secureKeys + m.Enabled = true + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{ + ModelName: alias, + Model: modelID, + APIBase: apiBase, + APIKeys: secureKeys, + Enabled: true, + }) + } + + cfg.Agents.Defaults.ModelName = alias + + if err := config.SaveConfig(configPath, cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Fprintf(stdout, "✓ Saved model '%s' (%s) and set as default.\n", alias, modelID) + return nil +} diff --git a/cmd/picoclaw/internal/model/add_test.go b/cmd/picoclaw/internal/model/add_test.go new file mode 100644 index 000000000..5da4d5e7f --- /dev/null +++ b/cmd/picoclaw/internal/model/add_test.go @@ -0,0 +1,257 @@ +package model + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestFetchOpenAIModels_DataEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/models", r.URL.Path) + assert.Equal(t, "Bearer secret", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-foo","name":"Foo"},{"id":"gpt-bar"}]}`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "secret") + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "gpt-foo", entries[0].ID) + assert.Equal(t, "Foo", entries[0].Name) + assert.Equal(t, "gpt-bar", entries[1].ID) +} + +func TestFetchOpenAIModels_BareArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"a"},{"id":"b"}]`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "secret") + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, "a", entries[0].ID) + assert.Equal(t, "b", entries[1].ID) +} + +func TestFetchOpenAIModels_TrimsTrailingSlash(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"data":[{"id":"x"}]}`)) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL+"/", "k") + require.NoError(t, err) + assert.Equal(t, "/models", gotPath) +} + +func TestFetchOpenAIModels_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL, "bad") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 401") +} + +func TestFetchOpenAIModels_EmptyDataEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "k") + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestFetchOpenAIModels_EmptyBareArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + entries, err := fetchOpenAIModels(srv.URL, "k") + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestFetchOpenAIModels_UnrecognizedShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"models":"not-supported"}`)) + })) + defer srv.Close() + + _, err := fetchOpenAIModels(srv.URL, "k") + require.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized shape") +} + +func TestFetchOpenAIModels_RequiresInputs(t *testing.T) { + _, err := fetchOpenAIModels("", "k") + require.Error(t, err) + assert.Contains(t, err.Error(), "api base") + + _, err = fetchOpenAIModels("https://example.com", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "api key") +} + +func TestPickModel_ByIndex(t *testing.T) { + entries := []modelEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("2\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "b", got) + assert.Contains(t, out.String(), "3 model(s) available") +} + +func TestPickModel_ByID(t *testing.T) { + entries := []modelEntry{{ID: "alpha"}, {ID: "beta"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("beta\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "beta", got) +} + +func TestPickModel_RetriesOnInvalid(t *testing.T) { + entries := []modelEntry{{ID: "x"}} + out := &bytes.Buffer{} + got, err := pickModel(strings.NewReader("\n9\nnot-a-model\nx\n"), out, entries) + require.NoError(t, err) + assert.Equal(t, "x", got) + rendered := out.String() + assert.Contains(t, rendered, "Out of range") + assert.Contains(t, rendered, "Not a valid number") +} + +func TestRunAdd_WithExplicitModel_NoNetwork(t *testing.T) { + initTest(t) + + out := &bytes.Buffer{} + err := runAdd(addOptions{ + apiBase: "https://invalid.invalid/v1", + apiKey: "k", + modelID: "explicit-model", + alias: "myalias", + modelType: "openai-compatible", + stdout: out, + }) + require.NoError(t, err) + assert.Contains(t, out.String(), "Saved model 'myalias' (explicit-model)") + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, "myalias", cfg.Agents.Defaults.GetModelName()) + added := findModelByName(cfg, "myalias") + require.NotNil(t, added, "expected model 'myalias' in model_list") + assert.Equal(t, "explicit-model", added.Model) + assert.Equal(t, "https://invalid.invalid/v1", added.APIBase) + assert.True(t, added.Enabled) + require.Len(t, added.APIKeys, 1) + assert.Equal(t, "k", added.APIKeys[0].String()) +} + +func findModelByName(cfg *config.Config, name string) *config.ModelConfig { + for _, m := range cfg.ModelList { + if m != nil && m.ModelName == name { + return m + } + } + return nil +} + +func TestRunAdd_FetchAndPick(t *testing.T) { + initTest(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer my-key", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"data":[{"id":"m1"},{"id":"m2"}]}`)) + })) + defer srv.Close() + + out := &bytes.Buffer{} + err := runAdd(addOptions{ + apiBase: srv.URL, + apiKey: "my-key", + alias: defaultAliasName, + modelType: "openai-compatible", + stdin: strings.NewReader("2\n"), + stdout: out, + }) + require.NoError(t, err) + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + assert.Equal(t, defaultAliasName, cfg.Agents.Defaults.GetModelName()) + added := findModelByName(cfg, defaultAliasName) + require.NotNil(t, added) + assert.Equal(t, "m2", added.Model) +} + +func TestRunAdd_UpsertsExistingAlias(t *testing.T) { + initTest(t) + + first := &bytes.Buffer{} + require.NoError(t, runAdd(addOptions{ + apiBase: "https://a.example/v1", + apiKey: "k1", + modelID: "m1", + alias: "shared", + stdout: first, + })) + + second := &bytes.Buffer{} + require.NoError(t, runAdd(addOptions{ + apiBase: "https://b.example/v1", + apiKey: "k2", + modelID: "m2", + alias: "shared", + stdout: second, + })) + + cfg, err := config.LoadConfig(configPath) + require.NoError(t, err) + matches := 0 + for _, m := range cfg.ModelList { + if m != nil && m.ModelName == "shared" { + matches++ + } + } + assert.Equal(t, 1, matches, "alias should be updated, not duplicated") + + updated := findModelByName(cfg, "shared") + require.NotNil(t, updated) + assert.Equal(t, "m2", updated.Model) + assert.Equal(t, "https://b.example/v1", updated.APIBase) + assert.Equal(t, "k2", updated.APIKeys[0].String()) +} + +func TestRunAdd_RejectsUnsupportedType(t *testing.T) { + initTest(t) + + err := runAdd(addOptions{ + apiBase: "https://x/v1", + apiKey: "k", + modelID: "m", + alias: "a", + modelType: "anthropic", + stdout: &bytes.Buffer{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --type") +} diff --git a/cmd/picoclaw/internal/model/command.go b/cmd/picoclaw/internal/model/command.go index 330734b82..c412993a0 100644 --- a/cmd/picoclaw/internal/model/command.go +++ b/cmd/picoclaw/internal/model/command.go @@ -21,11 +21,17 @@ func NewModelCommand() *cobra.Command { If no argument is provided, shows the current default model. If a model name is provided, sets it as the default model. +To onboard a model from a custom OpenAI-compatible endpoint (fetch the +available list online and pick one), use the 'add' subcommand: + + picoclaw model add --help + Examples: picoclaw model # Show current default model picoclaw model gpt-5.2 # Set gpt-5.2 as default picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default picoclaw model local-model # Set local VLLM server as default + picoclaw model add -b URL -k KEY # Add a model from a custom endpoint Note: 'local-model' is a special value for using a local VLLM server (running at localhost:8000 by default) which does not require an API key.`, @@ -51,6 +57,8 @@ Note: 'local-model' is a special value for using a local VLLM server }, } + cmd.AddCommand(newAddCommand()) + return cmd } @@ -66,6 +74,9 @@ func showCurrentModel(cfg *config.Config) { fmt.Println("\nAvailable models in your config:") listAvailableModels(cfg) } + + fmt.Println("\nTip: 'picoclaw model add -b URL -k KEY' adds a model from a custom") + fmt.Println(" OpenAI-compatible endpoint (see 'picoclaw model add --help').") } func listAvailableModels(cfg *config.Config) { diff --git a/cmd/picoclaw/internal/model/online.go b/cmd/picoclaw/internal/model/online.go new file mode 100644 index 000000000..9b8f7811d --- /dev/null +++ b/cmd/picoclaw/internal/model/online.go @@ -0,0 +1,77 @@ +package model + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type modelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type modelsAPIResponse struct { + Data []modelEntry `json:"data"` +} + +// fetchOpenAIModels GETs /models with Bearer auth and accepts both the +// {data:[…]} envelope and a bare array shape used by various OpenAI-compatible servers. +func fetchOpenAIModels(baseURL, apiKey string) ([]modelEntry, error) { + if strings.TrimSpace(baseURL) == "" { + return nil, fmt.Errorf("api base is required") + } + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("api key is required") + } + + url := strings.TrimRight(baseURL, "/") + "/models" + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + // {"data": [...]} envelope. Distinguish "envelope shape with empty list" + // from "object without a data key" via Data being non-nil after unmarshal: + // json.Unmarshal sets Data to []modelEntry{} for `{"data":[]}` but leaves + // it as nil when "data" is absent or null. + var envelope modelsAPIResponse + if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil { + return envelope.Data, nil + } + + // Bare-array shape, including `[]`. + var arr []modelEntry + if err := json.Unmarshal(body, &arr); err == nil { + return arr, nil + } + + preview := body + if len(preview) > 256 { + preview = preview[:256] + } + return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview))) +} diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index 4be19b2a5..bf8f4104f 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -//go:generate cp -r ../../../../workspace . +//go:generate go run ../../../../scripts/copydir.go ../../../../workspace ./workspace //go:embed workspace var embeddedFiles embed.FS diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 0867203a6..abcf03a34 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" @@ -87,6 +88,7 @@ picoclaw --no-color status`, gateway.NewGatewayCommand(), status.NewStatusCommand(), cron.NewCronCommand(), + mcp.NewMCPCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), model.NewModelCommand(), diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 309e60ba9..037c7c2e6 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) { "auth", "cron", "gateway", + "mcp", "migrate", "model", "onboard", diff --git a/config/config.example.json b/config/config.example.json index 858472488..4205b8e8a 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -13,7 +13,8 @@ "split_on_marker": false, "tool_feedback": { "enabled": false, - "max_args_length": 300 + "max_args_length": 300, + "separate_messages": false } } }, @@ -436,6 +437,9 @@ "enabled": true, "mode": "bytes" }, + "serial": { + "enabled": false + }, "send_tts": { "enabled": false }, diff --git a/docker/Dockerfile.full b/docker/Dockerfile.full index 30e1680d5..1dc0679c9 100644 --- a/docker/Dockerfile.full +++ b/docker/Dockerfile.full @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ -FROM golang:1.26.0-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make diff --git a/docker/Dockerfile.goreleaser.launcher b/docker/Dockerfile.goreleaser.launcher index 0a20a90b3..97944afc1 100644 --- a/docker/Dockerfile.goreleaser.launcher +++ b/docker/Dockerfile.goreleaser.launcher @@ -6,7 +6,6 @@ RUN apk add --no-cache ca-certificates tzdata COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher -COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui ENTRYPOINT ["picoclaw-launcher"] CMD ["-console", "-public", "-no-browser"] diff --git a/docker/Dockerfile.heavy b/docker/Dockerfile.heavy index 2a9fc742d..81f6976a2 100644 --- a/docker/Dockerfile.heavy +++ b/docker/Dockerfile.heavy @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ -FROM golang:1.26.0-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make diff --git a/docker/Dockerfile.launcher b/docker/Dockerfile.launcher new file mode 100644 index 000000000..33fdc6d6e --- /dev/null +++ b/docker/Dockerfile.launcher @@ -0,0 +1,65 @@ +# ============================================================ +# Stage 1: Build frontend assets (Node.js + pnpm) +# ============================================================ +FROM node:24-alpine3.23 AS frontend + +RUN corepack enable && corepack prepare pnpm@latest --activate + +WORKDIR /src/web/frontend + +# Cache frontend dependencies +COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./ +RUN CI=true pnpm install --frozen-lockfile + +# Build frontend +COPY web/frontend/ ./ +RUN pnpm build:backend + +# ============================================================ +# Stage 2: Build Go binaries (picoclaw + picoclaw-launcher) +# ============================================================ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git make + +WORKDIR /src + +# Cache Go dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Copy pre-built frontend assets into the backend embed directory +COPY --from=frontend /src/web/backend/dist web/backend/dist + +# Build picoclaw binary (includes go generate) +RUN make build + +# Build picoclaw-launcher binary (frontend already built in stage 1) +# Mirror ldflags from web/Makefile to inject version metadata +RUN CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config && \ + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) && \ + GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo dev) && \ + BUILD_TIME=$(date +%FT%T%z) && \ + GO_VERSION=$(go env GOVERSION) && \ + CGO_ENABLED=0 go build -v -tags goolm,stdjson \ + -ldflags "-X ${CONFIG_PKG}.Version=${VERSION} -X ${CONFIG_PKG}.GitCommit=${GIT_COMMIT} -X ${CONFIG_PKG}.BuildTime=${BUILD_TIME} -X ${CONFIG_PKG}.GoVersion=${GO_VERSION} -s -w" \ + -o build/picoclaw-launcher ./web/backend/ + +# ============================================================ +# Stage 3: Minimal runtime image +# ============================================================ +FROM alpine:3.23 + +RUN apk add --no-cache ca-certificates tzdata curl + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:18790/health || exit 1 + +COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw +COPY --from=builder /src/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher + +ENTRYPOINT ["picoclaw-launcher"] +CMD ["-console", "-public", "-no-browser"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7c940621f..b12959fc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,6 +4,9 @@ services: # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello" # ───────────────────────────────────────────── picoclaw-agent: + build: + context: .. + dockerfile: docker/Dockerfile image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-agent profiles: @@ -22,6 +25,9 @@ services: # docker compose -f docker/docker-compose.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: + build: + context: .. + dockerfile: docker/Dockerfile image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-gateway restart: unless-stopped @@ -38,6 +44,9 @@ services: # docker compose -f docker/docker-compose.yml --profile launcher up # ───────────────────────────────────────────── picoclaw-launcher: + build: + context: .. + dockerfile: docker/Dockerfile.launcher image: docker.io/sipeed/picoclaw:launcher container_name: picoclaw-launcher restart: unless-stopped diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b6fc724b5..6fafb5150 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -12,4 +12,10 @@ if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.js exit 0 fi +# Remove stale PID file from a previous container run. +# After docker kill / OOM / crash the PID file may linger on the bind-mounted +# volume and block the next gateway start (the recorded PID could collide with +# an unrelated process inside the new container). +rm -f "${HOME}/.picoclaw/.picoclaw.pid" + exec picoclaw gateway "$@" diff --git a/docs/design/current-hardware-support-and-serial.zh.md b/docs/design/current-hardware-support-and-serial.zh.md new file mode 100644 index 000000000..bf91355c2 --- /dev/null +++ b/docs/design/current-hardware-support-and-serial.zh.md @@ -0,0 +1,91 @@ +# 当前硬件支持现状与串口 Tool 方案 + +## 现状结论 + +当前项目已有的硬件相关能力主要分为两条线: + +1. 设备事件监控 + - `pkg/devices` 已实现设备事件服务。 + - 当前只有 Linux USB 热插拔事件源 `pkg/devices/sources/usb_linux.go`。 + - 能力定位是“发现和通知”,不是“总线读写控制”。 + +2. 硬件控制 Tool + - `pkg/tools/hardware/i2c*.go`:I2C Tool,支持 `detect`、`scan`、`read`、`write`。 + - `pkg/tools/hardware/spi*.go`:SPI Tool,支持 `list`、`transfer`、`read`。 + - 这两类 Tool 当前都只在 Linux 主机上启用,直接依赖 `/dev/i2c-*` 与 `/dev/spidev*`。 + +因此,项目在“硬件支持能力”上已经具备: + +- Linux USB 设备插拔感知 +- Linux I2C 总线控制 +- Linux SPI 总线控制 + +但还缺少: + +- 串口/UART 控制 +- macOS / Windows 下可直接使用的硬件控制 Tool +- 面向统一硬件抽象的跨总线能力模型 + +## 本次新增 + +本次新增内建 `serial` Tool,并接入现有 Tool 体系: + +- 配置项:`tools.serial.enabled` +- Tool 注册:`pkg/agent/agent_init.go` +- Web 工具页:`/api/tools` 能展示与切换 `serial` +- 前端状态文案:新增 `requires_serial_platform` + +## Serial Tool 设计 + +`serial` 采用无状态调用模型,每次请求都自行打开和关闭端口,避免在 agent 回合之间维护串口会话状态。 + +支持动作: + +- `list`:枚举主机串口 +- `read`:从串口读取指定长度字节 +- `write`:向串口写入字节或文本 + +公共参数: + +- `port` +- `baud` +- `data_bits` +- `parity` +- `stop_bits` +- `timeout_ms` + +当前波特率实现边界: + +- Windows 允许配置工具层接受的范围 `50-4000000` +- Linux / macOS 当前仅支持标准 termios 波特率,实际支持到 `230400` +- 因此 `baud` 的跨平台可移植取值应优先使用 `230400` 及以下的常见标准速率 + +安全约束: + +- `write` 必须显式传 `confirm: true` +- 单次读写负载限制为 `4096` 字节 +- `port` 只接受白名单串口名: + - Linux / macOS 仅允许 `/dev/tty*`、`/dev/cu.*` 及对应简写设备名 + - Windows 仅允许 `COM\d+` 或 `\\.\COM\d+` + - 明确拒绝 `..`、普通文件绝对路径、盘符路径等非串口设备路径,避免路径穿越或误打开任意文件 + +## 跨平台实现边界 + +- Linux / macOS: + - 基于 `golang.org/x/sys/unix` 和 termios 配置串口参数。 + - 当前仅接入标准 termios 波特率映射,最高到 `230400`,尚未扩展 `460800`、`921600`、`1000000`、`2000000` 等更高速率。 + - 通过 `/dev/...` 枚举和访问设备。 + +- Windows: + - 基于 `kernel32` 串口 API 配置 `DCB` 和 `COMMTIMEOUTS`。 + - 当前读写仍使用同步 `ReadFile` / `WriteFile`;一旦 syscall 已进入执行,turn context cancellation 不能立即打断,只能等待 `COMMTIMEOUTS` 触发后返回。 + - 通过注册表 `HARDWARE\\DEVICEMAP\\SERIALCOMM` 枚举端口。 + +- 其他平台: + - `serial` Tool 显式返回 unsupported,不做静默降级。 + +## 后续建议 + +1. 如果需要持续交互式串口会话,建议再增加 session 型 Tool,而不是让 LLM 反复做短连接轮询。 +2. 如果后续要支持 CAN、GPIO、PWM,建议抽出统一的硬件 capability 描述层,而不是继续只靠 Tool 名称区分。 +3. 若需要生产级稳定性,建议补真实串口回环测试,至少覆盖 Linux PTY 和 Windows COM 模拟场景。 diff --git a/docs/guides/docker.fr.md b/docs/guides/docker.fr.md index ed0d14cf3..e174298ac 100644 --- a/docs/guides/docker.fr.md +++ b/docs/guides/docker.fr.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Mode Launcher (Console Web) -L'image `launcher` inclut les trois binaires (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat. +L'image `launcher` inclut les deux binaires (`picoclaw`, `picoclaw-launcher`) et démarre la console web par défaut, qui fournit une interface navigateur pour la configuration et le chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.ja.md b/docs/guides/docker.ja.md index 8fa5ae60c..19199aaac 100644 --- a/docs/guides/docker.ja.md +++ b/docs/guides/docker.ja.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher モード (Web コンソール) -`launcher` イメージには 3 つのバイナリ(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`)がすべて含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 +`launcher` イメージには 2 つのバイナリ(`picoclaw`、`picoclaw-launcher`)が含まれており、デフォルトで Web コンソールを起動します。ブラウザベースの設定・チャット画面を提供します。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.md b/docs/guides/docker.md index e017538f7..e2e472fbf 100644 --- a/docs/guides/docker.md +++ b/docs/guides/docker.md @@ -39,7 +39,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher Mode (Web Console) -The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. +The `launcher` image includes both binaries (`picoclaw`, `picoclaw-launcher`) and starts the web console by default, which provides a browser-based UI for configuration and chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.ms.md b/docs/guides/docker.ms.md index 7adab6759..5a426cb99 100644 --- a/docs/guides/docker.ms.md +++ b/docs/guides/docker.ms.md @@ -35,7 +35,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Mod Launcher (Konsol Web) -Imej `launcher` merangkumi ketiga-tiga binari (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang. +Imej `launcher` merangkumi kedua-dua binari (`picoclaw`, `picoclaw-launcher`) dan memulakan konsol web secara lalai, yang menyediakan UI berasaskan pelayar untuk konfigurasi dan sembang. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.pt-br.md b/docs/guides/docker.pt-br.md index d7d55e753..ab71af8e6 100644 --- a/docs/guides/docker.pt-br.md +++ b/docs/guides/docker.pt-br.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Modo Launcher (Console Web) -A imagem `launcher` inclui os três binários (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat. +A imagem `launcher` inclui ambos os binários (`picoclaw`, `picoclaw-launcher`) e inicia o console web por padrão, que fornece uma interface baseada em navegador para configuração e chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.vi.md b/docs/guides/docker.vi.md index 05f1b3d68..e91450bb0 100644 --- a/docs/guides/docker.vi.md +++ b/docs/guides/docker.vi.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Chế Độ Launcher (Web Console) -Image `launcher` bao gồm cả ba binary (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat. +Image `launcher` bao gồm cả hai binary (`picoclaw`, `picoclaw-launcher`) và khởi động web console mặc định, cung cấp giao diện trình duyệt để cấu hình và chat. ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/guides/docker.zh.md b/docs/guides/docker.zh.md index bed445751..855375d9c 100644 --- a/docs/guides/docker.zh.md +++ b/docs/guides/docker.zh.md @@ -36,7 +36,7 @@ docker compose -f docker/docker-compose.yml --profile gateway down ### Launcher 模式 (Web 控制台) -`launcher` 镜像包含所有三个二进制文件(`picoclaw`、`picoclaw-launcher`、`picoclaw-launcher-tui`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 +`launcher` 镜像包含两个二进制文件(`picoclaw`、`picoclaw-launcher`),默认启动 Web 控制台,提供基于浏览器的配置和聊天界面。 ```bash docker compose -f docker/docker-compose.yml --profile launcher up -d diff --git a/docs/operations/debug.md b/docs/operations/debug.md index b9e776f0f..eacd72380 100644 --- a/docs/operations/debug.md +++ b/docs/operations/debug.md @@ -65,7 +65,8 @@ Debug logs are server-side only. If you want the agent to send a visible notific "defaults": { "tool_feedback": { "enabled": true, - "max_args_length": 300 + "max_args_length": 300, + "separate_messages": true } } } @@ -85,6 +86,7 @@ When `enabled` is `true`, every tool call sends a short message to the chat befo | Field | Type | Default | Description | |---|---|---|---| | `enabled` | bool | `false` | Send a chat notification for each tool call | +| `separate_messages` | bool | `false` | Keep every tool feedback update as a separate chat message instead of reusing a single placeholder/progress message | | `max_args_length` | int | `300` | Maximum characters of the serialised arguments included in the notification | ### Environment variables diff --git a/docs/project/README.fr.md b/docs/project/README.fr.md index 1e2f59bee..b02067d2a 100644 --- a/docs/project/README.fr.md +++ b/docs/project/README.fr.md @@ -292,24 +292,6 @@ Après cette étape unique, `picoclaw-launcher` s'ouvrira normalement lors des l -### 💻 TUI Launcher (Recommandé pour les environnements sans interface / SSH) - -Le TUI (Terminal UI) Launcher fournit une interface terminal complète pour la configuration et la gestion. Idéal pour les serveurs, Raspberry Pi et autres environnements sans interface graphique. - -```bash -picoclaw-launcher-tui -``` - -

-TUI Launcher -

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

-TUI Launcher -

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

-TUI Launcher -

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

-TUI Launcher -

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

-TUI Launcher -

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

-Pelancar TUI -

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

-TUI Launcher -

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

-TUI Launcher -

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

-TUI Launcher -

- -**开始使用:** - -通过 TUI 菜单:**1)** 配置 Provider -> **2)** 配置 Channel -> **3)** 启动 Gateway -> **4)** 开始聊天! - -详细 TUI 文档请参阅 [docs.picoclaw.io](https://docs.picoclaw.io)。 - ### 📱 Android diff --git a/docs/reference/README.md b/docs/reference/README.md index eec5c09b4..2e0f53cf7 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -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. diff --git a/docs/reference/mcp-cli.md b/docs/reference/mcp-cli.md new file mode 100644 index 000000000..18b2b4c1c --- /dev/null +++ b/docs/reference/mcp-cli.md @@ -0,0 +1,361 @@ +# MCP Server CLI + +> Back to [README](../README.md) + +PicoClaw includes an `mcp` CLI command group for managing MCP server entries in `config.json`. + +This CLI acts as a **configuration manager**: + +- it adds, updates, removes, and validates entries under `tools.mcp.servers` +- it does **not** keep MCP servers running itself +- the gateway / host still starts the configured servers when MCP is enabled + +## Where It Writes + +The CLI updates the same config file used by the rest of PicoClaw: + +- `PICOCLAW_CONFIG` if set +- otherwise `~/.picoclaw/config.json` + +When the CLI writes the file, it: + +- saves atomically +- preserves the standard 2-space JSON formatting used by PicoClaw +- validates the generated JSON before writing + +Behavior notes: + +- `picoclaw mcp add ...` enables `tools.mcp.enabled` +- removing the last server with `picoclaw mcp remove ...` disables `tools.mcp.enabled` + +## Quick Start + +Add a stdio server via `npx`: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +``` + +Add a stdio server with environment variables saved in config: + +```bash +picoclaw mcp add github --env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx -- npx -y @modelcontextprotocol/server-github +``` + +Add a stdio server using an env file for secrets: + +```bash +picoclaw mcp add github --env-file .env.github -- npx -y @modelcontextprotocol/server-github +``` + +Add a remote HTTP server: + +```bash +picoclaw mcp add context7 --transport http https://mcp.context7.com/mcp +``` + +Add a remote HTTP server with auth header, even with flags after the URL: + +```bash +picoclaw mcp add apify "https://mcp.apify.com/" -t http --header "Authorization: Bearer OMITTED" +``` + +Add a stdio server using an explicit command separator: + +```bash +picoclaw mcp add --transport stdio --env AIRTABLE_API_KEY=YOUR_KEY airtable -- npx -y airtable-mcp-server +``` + +Inspect the configured entries: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +Inspect one server's full details and its exposed tools: + +```bash +picoclaw mcp show filesystem +``` + +Probe a single server entry: + +```bash +picoclaw mcp test filesystem +``` + +Open the raw config for advanced editing: + +```bash +picoclaw mcp edit +``` + +## Command Summary + +| Command | Purpose | +|---------|---------| +| `picoclaw mcp add [flags] [args...]` | Add or update an MCP server entry | +| `picoclaw mcp remove ` | Remove a server entry from config | +| `picoclaw mcp list` | List configured MCP servers | +| `picoclaw mcp show ` | Show full details and tools for one server | +| `picoclaw mcp test ` | Try connecting to one configured server | +| `picoclaw mcp edit` | Open `config.json` in `$EDITOR` | + +## `picoclaw mcp add` + +Syntax: + +```bash +picoclaw mcp add [flags] [args...] +``` + +Supported flags: + +| Flag | Meaning | +|------|---------| +| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. Values are saved to config. | +| `--env-file` | Attach an env file path to a stdio server. Recommended for secrets you do not want stored inline in `config.json`. | +| `--header`, `-H` | Add an HTTP header in `Name: Value` or `Name=Value` format. Repeatable. | +| `--transport`, `-t` | Transport type: `stdio` (default), `http`, or `sse`. | +| `--force`, `-f` | Overwrite an existing server entry without confirmation. | +| `--deferred` | Mark the server as deferred: tools are hidden and discoverable on demand. | +| `--no-deferred` | Mark the server as non-deferred: tools are always loaded into context. | + +When neither `--deferred` nor `--no-deferred` is passed, the `deferred` field is omitted from the stored config and the global `discovery.enabled` value applies at runtime. + +Supported forms: + +```bash +picoclaw mcp add [flags] [args...] +picoclaw mcp add [flags] -- [args...] +``` + +Parsing behavior: + +- CLI flags can appear before the name, between the name and target, or after the URL for remote transports +- for `stdio`, the most robust form is `-- [args...]` +- use the `--` separator when the stdio command itself has arguments that may look like PicoClaw CLI flags +- without `--`, PicoClaw treats the first two non-flag tokens as `` and `` + +Secret handling: + +- `--env KEY=value` stores the resolved value directly in `config.json` +- use `--env-file` instead when the value is sensitive and should stay outside the main config file + +Example: + +```bash +picoclaw mcp add sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +This stores: + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"] + } + } + } + } +} +``` + +Adding the same server with `--deferred` stores the extra field: + +```bash +picoclaw mcp add --deferred sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +```json +{ + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"], + "deferred": true + } +} +``` + +### Add Command Rules + +For `stdio`: + +- `` is treated as the command +- `[args...]` are stored in `args` +- `--env` is supported +- `--env-file` is supported and stored in `env_file` +- `--header` is rejected +- `-- [args...]` is supported and recommended for unambiguous parsing + +For `http` / `sse`: + +- `` must be a valid URL +- extra command args are rejected +- `--env` is rejected +- `--env-file` is rejected +- `--header` is supported and stored in `headers` + +Overwrite behavior: + +- if `` already exists, PicoClaw asks for confirmation +- use `--force` to skip the prompt + +Local path validation: + +- if the command looks like a local path such as `./server.py` or `/opt/mcp/server` +- PicoClaw checks that the file exists +- on non-Windows platforms, it also checks that the file is executable + +Clear URL/transport error: + +- if the target looks like `https://...` but transport is still `stdio`, PicoClaw returns an explicit error telling you to use `--transport http` or `--transport sse` + +## `picoclaw mcp remove` + +Syntax: + +```bash +picoclaw mcp remove +``` + +This removes the named entry from `tools.mcp.servers`. + +If the removed server was the last configured MCP server, PicoClaw also disables `tools.mcp.enabled`. + +## `picoclaw mcp list` + +Syntax: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +On wide terminals the output is a styled box (same look as `mcp show`). On narrow terminals or when stdout is not a TTY, a plain ASCII table is printed instead. + +Output fields: + +| Field | Meaning | +|-------|---------| +| `Name` | Server key inside `tools.mcp.servers` | +| `Type` | Effective transport: `stdio`, `http`, or `sse` | +| `Command` / `Target` | Stored command line for stdio servers, or URL for remote servers | +| `Status` | `enabled` / `disabled` by default; with `--status`: `ok (N tools)` or `error` | +| `Deferred` | `deferred` if the per-server override is `true`; `eager` if `false`; omitted if not set | + +Notes: + +- without `--status`, PicoClaw prints configuration state only +- with `--status`, PicoClaw tries to connect to each enabled server and reports `ok (N tools)` or `error` +- to see the full list of tools a server exposes, use `picoclaw mcp show ` + +## `picoclaw mcp show` + +Syntax: + +```bash +picoclaw mcp show +picoclaw mcp show --timeout 15s +``` + +This connects to the named server and prints: + +- server metadata: name, transport type, target, enabled state, deferred override, env var names, env file, header names +- every tool the server exposes, with its name, description, and parameters (name, type, required/optional, description) + +On wide terminals the output is a styled box matching the `mcp list` look. On narrow terminals or non-TTY stdout, plain text is printed instead. + +Example output (wide terminal): + +``` +╭──────────────────────────────────────────────────────────╮ +│ ⬡ filesystem │ +│ │ +│ Type stdio │ +│ Target npx -y @modelcontextprotocol/server-fs /tmp │ +│ Enabled yes │ +│ Deferred no │ +│ │ +│ Tools (3) │ +│ │ +│ read_file [1/3] │ +│ Read the complete contents of a file from the disk │ +│ │ +│ path required │ +│ Path to the file to read │ +│ ──────────────────────────────────────────────────────── │ +│ ... │ +╰──────────────────────────────────────────────────────────╯ +``` + +Flags: + +| Flag | Default | Meaning | +|------|---------|---------| +| `--timeout` | `10s` | Connection timeout | + +Notes: + +- if the server is disabled in config, `mcp show` prints the metadata only and skips tool discovery +- `mcp show` always connects live to fetch the tool list; use `mcp test` if you only need a reachability check + +## `picoclaw mcp test` + +Syntax: + +```bash +picoclaw mcp test +``` + +This performs a direct connection test for one configured entry and prints the number of discovered tools when successful. + +It is useful when: + +- you want to verify a newly added server before starting the gateway +- you want to debug one server without probing the whole list +- the entry is currently disabled in config but you still want to validate its definition + +## `picoclaw mcp edit` + +Syntax: + +```bash +picoclaw mcp edit +``` + +This opens the config file in the editor pointed to by `$EDITOR`. + +Use it when you need to configure MCP fields that are not exposed directly by `picoclaw mcp add`. + +If `$EDITOR` is not set, the command fails with an explicit error. + +## Recommended Workflow + +For common cases: + +1. Add the server with `picoclaw mcp add` (include `--deferred` if you want tools hidden by default). +2. Verify connectivity and inspect the exposed tools with `picoclaw mcp show `. +3. Check all servers at a glance with `picoclaw mcp list --status`. +4. Start PicoClaw normally so the configured MCP server is loaded by the host. + +For advanced cases: + +1. Add the base entry with `picoclaw mcp add`. +2. Run `picoclaw mcp edit` to fill in fields that are not exposed as CLI flags. +3. Run `picoclaw mcp show ` to confirm the final configuration and tool list. + +## Related Docs + +- [Tools Configuration](tools_configuration.md#mcp-tool): MCP config structure, transports, discovery, and examples +- [README](../README.md): high-level overview diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md index fa33f0bb4..810d91ef2 100644 --- a/docs/reference/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -258,6 +258,17 @@ For schedule types, execution modes (`deliver`, agent turn, and command jobs), p The MCP tool enables integration with external Model Context Protocol servers. +If you prefer not to edit JSON manually, PicoClaw also provides an MCP configuration manager CLI: + +- `picoclaw mcp add` — add or update a server (supports `--deferred` / `--no-deferred`) +- `picoclaw mcp list` — list all configured servers with status and deferred state +- `picoclaw mcp show ` — show full details and the tool list for one server +- `picoclaw mcp test ` — connectivity check for one server +- `picoclaw mcp remove ` — remove a server entry +- `picoclaw mcp edit` — open `config.json` in `$EDITOR` for advanced edits + +These commands manage the same `tools.mcp.servers` section documented below. See [MCP Server CLI](mcp-cli.md) for command syntax, examples, and behavior details. + ### Tool Discovery (Lazy Loading) When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window diff --git a/go.mod b/go.mod index a8b540662..c7e77c0f9 100644 --- a/go.mod +++ b/go.mod @@ -4,26 +4,24 @@ go 1.25.9 require ( fyne.io/systray v1.12.0 - github.com/BurntSushi/toml v1.6.0 github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/atc0005/go-teams-notify/v2 v2.14.0 - github.com/aws/aws-sdk-go-v2 v1.41.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 github.com/creack/pty v1.1.24 github.com/ergochat/irc-go v0.6.0 github.com/ergochat/readline v0.1.3 - github.com/gdamore/tcell/v2 v2.13.8 github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 - github.com/larksuite/oapi-sdk-go/v3 v3.5.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 @@ -33,8 +31,7 @@ require ( github.com/openai/openai-go/v3 v3.22.0 github.com/pion/rtp v1.10.1 github.com/pion/webrtc/v3 v3.3.6 - github.com/rivo/tview v0.42.0 - github.com/rs/zerolog v1.35.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 +52,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 @@ -79,7 +76,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect - github.com/gdamore/encoding v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect @@ -122,7 +118,7 @@ require ( github.com/github/copilot-sdk/go v0.2.0 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/jsonschema-go v0.4.2 github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index f63c7b44e..5cd39ec8d 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg= @@ -23,38 +21,38 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo= github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= -github.com/aws/aws-sdk-go-v2 v1.41.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= @@ -105,10 +103,6 @@ github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4p github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= -github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= -github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0= github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -138,8 +132,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 +177,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/larksuite/oapi-sdk-go/v3 v3.5.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= @@ -234,8 +226,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= -github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -243,8 +233,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= diff --git a/pkg/agent/adapters/channelmanager.go b/pkg/agent/adapters/channelmanager.go index 8265ef99d..ad0840e86 100644 --- a/pkg/agent/adapters/channelmanager.go +++ b/pkg/agent/adapters/channelmanager.go @@ -43,3 +43,9 @@ func (a *channelManagerAdapter) SendMedia(ctx context.Context, msg bus.OutboundM func (a *channelManagerAdapter) SendPlaceholder(ctx context.Context, channel, chatID string) bool { return a.inner.SendPlaceholder(ctx, channel, chatID) } + +func (a *channelManagerAdapter) DismissToolFeedback( + ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext, +) { + a.inner.DismissToolFeedback(ctx, channel, chatID, outboundCtx) +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 3e9bd845e..2c456dca7 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -111,8 +111,10 @@ const ( sessionKeyAgentPrefix = "agent:" pendingTurnPrefix = "pending-" metadataKeyMessageKind = "message_kind" + metadataKeyToolCalls = "tool_calls" messageKindThought = "thought" messageKindToolFeedback = "tool_feedback" + messageKindToolCalls = "tool_calls" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go index 611d634e8..335fd8537 100644 --- a/pkg/agent/agent_init.go +++ b/pkg/agent/agent_init.go @@ -128,6 +128,9 @@ func registerSharedTools( if cfg.Tools.IsToolEnabled("spi") { agent.Tools.Register(tools.NewSPITool()) } + if cfg.Tools.IsToolEnabled("serial") { + agent.Tools.Register(tools.NewSerialTool()) + } // Message tool if cfg.Tools.IsToolEnabled("message") { diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index 251d32b58..fcb57a5d4 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -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) diff --git a/pkg/agent/agent_media.go b/pkg/agent/agent_media.go index 866f7dc24..d51677d3a 100644 --- a/pkg/agent/agent_media.go +++ b/pkg/agent/agent_media.go @@ -11,6 +11,7 @@ import ( "encoding/base64" "io" "os" + "regexp" "strings" "github.com/h2non/filetype" @@ -20,24 +21,59 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +// genericPlaceholderRegex matches generic media placeholders emitted by various +// channels: [image], [image: photo], [image: filename.jpg] — but NOT path tags +// like [image:/path/to/file] (path tags have no space after the colon). +var ( + imagePlaceholderRegex = regexp.MustCompile(`\[image(:\s+[^\]]*)?\]`) + audioPlaceholderRegex = regexp.MustCompile(`\[audio(:\s+[^\]]*)?\]`) + videoPlaceholderRegex = regexp.MustCompile(`\[video(:\s+[^\]]*)?\]`) + filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`) +) + // resolveMediaRefs resolves media:// refs in messages. -// Images are base64-encoded into the Media array for multimodal LLMs. -// Non-image files (documents, audio, video) have their local path injected -// into Content so the agent can access them via file tools like read_file. +// For user messages: images get path tags only ([image:/path]) so the LLM +// can decide whether to view them via load_image or operate on the file. +// For tool messages: images are base64-encoded and appended as a synthetic +// user message only after the contiguous tool-message block ends, so we don't +// break the tool-results-must-immediately-follow-assistant constraint that +// LLM APIs enforce. +// Non-image files always get path tags regardless of role. // Returns a new slice; original messages are not mutated. func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { if store == nil { return messages } - result := make([]providers.Message, len(messages)) - copy(result, messages) + result := make([]providers.Message, 0, len(messages)) + var pendingToolImages []string + + for idx, m := range messages { + // When leaving a tool-message block, flush any accumulated images + // as a synthetic user message. + if m.Role != "tool" && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil + } - for i, m := range result { if len(m.Media) == 0 { + result = append(result, m) + if idx == len(messages)-1 && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil + } continue } + msg := m resolved := make([]string, 0, len(m.Media)) var pathTags []string @@ -66,13 +102,13 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS } mime := detectMIME(localPath, meta) + pathTags = append(pathTags, buildPathTag(mime, localPath)) - if strings.HasPrefix(mime, "image/") { + if m.Role == "tool" && strings.HasPrefix(mime, "image/") { dataURL := encodeImageToDataURL(localPath, mime, info, maxSize) if dataURL != "" { - resolved = append(resolved, dataURL) + pendingToolImages = append(pendingToolImages, dataURL) } - continue } if strings.HasPrefix(mime, "audio/") { @@ -83,18 +119,69 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS continue } - pathTags = append(pathTags, buildPathTag(mime, localPath)) } - result[i].Media = resolved + msg.Media = resolved if len(pathTags) > 0 { - result[i].Content = injectPathTags(result[i].Content, pathTags) + msg.Content = injectPathTags(msg.Content, pathTags) + } + result = append(result, msg) + + // If this is the last message and we have pending images, flush them. + if idx == len(messages)-1 && len(pendingToolImages) > 0 { + result = append(result, providers.Message{ + Role: "user", + Content: "[Loaded image from tool result above]", + Media: pendingToolImages, + }) + pendingToolImages = nil } } return result } +// encodeImageToDataURL base64-encodes an image file into a data URL. +// Returns empty string if the file exceeds maxSize or encoding fails. +func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string { + if info.Size() > int64(maxSize) { + logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ + "path": localPath, + "size": info.Size(), + "max_size": maxSize, + }) + return "" + } + + f, err := os.Open(localPath) + if err != nil { + logger.WarnCF("agent", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + return "" + } + defer f.Close() + + prefix := "data:" + mime + ";base64," + encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) + var buf bytes.Buffer + buf.Grow(len(prefix) + encodedLen) + buf.WriteString(prefix) + + encoder := base64.NewEncoder(base64.StdEncoding, &buf) + if _, err := io.Copy(encoder, f); err != nil { + logger.WarnCF("agent", "Failed to encode media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + return "" + } + encoder.Close() + + return buf.String() +} + func buildArtifactTags(store media.MediaStore, refs []string) []string { if store == nil || len(refs) == 0 { return nil @@ -145,51 +232,12 @@ func detectMIME(localPath string, meta media.MediaMeta) string { return kind.MIME.Value } -// encodeImageToDataURL base64-encodes an image file into a data URL. -// Returns empty string if the file exceeds maxSize or encoding fails. -func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string { - if info.Size() > int64(maxSize) { - logger.WarnCF("agent", "Media file too large, skipping", map[string]any{ - "path": localPath, - "size": info.Size(), - "max_size": maxSize, - }) - return "" - } - - f, err := os.Open(localPath) - if err != nil { - logger.WarnCF("agent", "Failed to open media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - return "" - } - defer f.Close() - - prefix := "data:" + mime + ";base64," - encodedLen := base64.StdEncoding.EncodedLen(int(info.Size())) - var buf bytes.Buffer - buf.Grow(len(prefix) + encodedLen) - buf.WriteString(prefix) - - encoder := base64.NewEncoder(base64.StdEncoding, &buf) - if _, err := io.Copy(encoder, f); err != nil { - logger.WarnCF("agent", "Failed to encode media file", map[string]any{ - "path": localPath, - "error": err.Error(), - }) - return "" - } - encoder.Close() - - return buf.String() -} - // buildPathTag creates a structured tag exposing the local file path. -// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path]. +// Tag type is derived from MIME: [image:/path], [audio:/path], [video:/path], or [file:/path]. func buildPathTag(mime, localPath string) string { switch { + case strings.HasPrefix(mime, "image/"): + return "[image:" + localPath + "]" case strings.HasPrefix(mime, "audio/"): return "[audio:" + localPath + "]" case strings.HasPrefix(mime, "video/"): @@ -200,22 +248,41 @@ func buildPathTag(mime, localPath string) string { } // injectPathTags replaces generic media tags in content with path-bearing versions, -// or appends if no matching generic tag is found. +// or appends if no matching generic tag is found. Channels emit a few different +// placeholder formats — [image], [image: photo], [image: filename.jpg] — so we +// match all of them via regex while leaving path tags ([image:/path]) untouched. +// +// When content is structured data (e.g., JSON from Feishu interactive cards or +// post messages), tags are only injected via placeholder replacement — never +// appended — to avoid corrupting the payload. func injectPathTags(content string, tags []string) string { + isStructured := looksLikeJSON(content) for _, tag := range tags { - var generic string + var pattern *regexp.Regexp switch { + case strings.HasPrefix(tag, "[image:"): + pattern = imagePlaceholderRegex case strings.HasPrefix(tag, "[audio:"): - generic = "[audio]" + pattern = audioPlaceholderRegex case strings.HasPrefix(tag, "[video:"): - generic = "[video]" + pattern = videoPlaceholderRegex case strings.HasPrefix(tag, "[file:"): - generic = "[file]" + pattern = filePlaceholderRegex } - if generic != "" && strings.Contains(content, generic) { - content = strings.Replace(content, generic, tag, 1) - } else if content == "" { + if pattern != nil { + if loc := pattern.FindStringIndex(content); loc != nil { + content = content[:loc[0]] + tag + content[loc[1]:] + continue + } + } + + if isStructured { + content = tag + "\n" + content + continue + } + + if content == "" { content = tag } else { content += " " + tag @@ -223,3 +290,8 @@ func injectPathTags(content string, tags []string) string { } return content } + +func looksLikeJSON(s string) bool { + s = strings.TrimSpace(s) + return len(s) > 1 && s[0] == '{' +} diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go index 7e36e4ad8..1728f6f79 100644 --- a/pkg/agent/agent_outbound.go +++ b/pkg/agent/agent_outbound.go @@ -4,13 +4,17 @@ package agent import ( "context" + "encoding/json" "errors" "fmt" + "strings" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" ) func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool { @@ -123,6 +127,95 @@ func (al *AgentLoop) publishPicoReasoning(ctx context.Context, reasoningContent, } } +func (al *AgentLoop) publishPicoToolCallInterim( + ctx context.Context, + ts *turnState, + reasoningContent string, + content string, + toolCalls []providers.ToolCall, +) { + if ts == nil || ts.chatID == "" || al == nil || al.bus == nil { + return + } + + if strings.TrimSpace(reasoningContent) != "" { + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err := al.bus.PublishOutbound( + pubCtx, + outboundMessageForTurnWithKind(ts, reasoningContent, messageKindThought), + ) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico reasoning", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } + } + + if !ts.opts.AllowInterimPicoPublish { + return + } + + visibleToolCalls := utils.BuildVisibleToolCalls( + toolCalls, + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + duplicateToolCallContent := len(visibleToolCalls) > 0 && + utils.ToolCallExplanationDuplicatesContent(content, toolCalls) + + if strings.TrimSpace(content) != "" && !duplicateToolCallContent { + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err := al.bus.PublishOutbound(pubCtx, outboundMessageForTurn(ts, content)) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico interim assistant content", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } + } + + if len(visibleToolCalls) == 0 { + return + } + + rawToolCalls, err := json.Marshal(visibleToolCalls) + if err != nil { + logger.WarnCF("agent", "Failed to serialize pico tool calls", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + return + } + + msg := outboundMessageForTurnWithKind(ts, "", messageKindToolCalls) + if msg.Context.Raw == nil { + msg.Context.Raw = map[string]string{} + } + msg.Context.Raw[metadataKeyToolCalls] = string(rawToolCalls) + + pubCtx, pubCancel := context.WithTimeout(ctx, 3*time.Second) + err = al.bus.PublishOutbound(pubCtx, msg) + pubCancel() + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) && + !errors.Is(err, bus.ErrBusClosed) { + logger.WarnCF("agent", "Failed to publish pico tool calls", map[string]any{ + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + } +} + func (al *AgentLoop) handleReasoning( ctx context.Context, reasoningContent, channelName, channelID string, diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index b0aa3b468..f326e2acb 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -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 } @@ -1749,17 +1781,22 @@ func (m *artifactThenSendProvider) Chat( if messages[i].Role != "tool" { continue } - start := strings.Index(messages[i].Content, "[file:") - if start < 0 { - continue + for _, prefix := range []string{"[image:", "[file:", "[audio:", "[video:"} { + start := strings.Index(messages[i].Content, prefix) + if start < 0 { + continue + } + rest := messages[i].Content[start+len(prefix):] + end := strings.Index(rest, "]") + if end < 0 { + continue + } + artifactPath = rest[:end] + break } - rest := messages[i].Content[start+len("[file:"):] - end := strings.Index(rest, "]") - if end < 0 { - continue + if artifactPath != "" { + break } - artifactPath = rest[:end] - break } if artifactPath == "" { return nil, fmt.Errorf("provider did not receive artifact path in tool result") @@ -1860,12 +1897,34 @@ func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing. {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, } - got := toolFeedbackExplanationFromResponse(response, messages, 300) + got := toolFeedbackExplanationFromResponse(response, messages) if got != "Read README.md first" { t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got) } } +func TestSideQuestionResponseContent_FallsBackWhenContentIsWhitespace(t *testing.T) { + response := &providers.LLMResponse{ + Content: " \n\t ", + ReasoningContent: "reasoning fallback", + } + + if got := sideQuestionResponseContent(response); got != "reasoning fallback" { + t.Fatalf("sideQuestionResponseContent() = %q, want %q", got, "reasoning fallback") + } +} + +func TestResponseReasoningContent_FallsBackWhenReasoningIsWhitespace(t *testing.T) { + response := &providers.LLMResponse{ + Reasoning: " \n\t ", + ReasoningContent: "structured reasoning fallback", + } + + if got := responseReasoningContent(response); got != "structured reasoning fallback" { + t.Fatalf("responseReasoningContent() = %q, want %q", got, "structured reasoning fallback") + } +} + func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t *testing.T) { response := &providers.LLMResponse{ ToolCalls: []providers.ToolCall{{ @@ -1882,7 +1941,7 @@ func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, } - got := toolFeedbackExplanationFromResponse(response, messages, 300) + got := toolFeedbackExplanationFromResponse(response, messages) if got != "Read README.md first to confirm the current project structure." { t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got) } @@ -1909,8 +1968,8 @@ func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *t }, } - got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil, 300) - got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil, 300) + got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil) + got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil) if got1 != "Read README.md first." { t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1) } @@ -1939,7 +1998,7 @@ func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanati {Role: "user", Content: "inspect the config and update the example"}, } - got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages, 300) + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages) want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example" if got != want { t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want) @@ -1958,13 +2017,42 @@ func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testi {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, } - got := toolFeedbackExplanationFromResponse(response, messages, 300) + got := toolFeedbackExplanationFromResponse(response, messages) want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example." if got != want { t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got) } } +func TestToolFeedbackExplanationForToolCall_DoesNotTruncateLongExplanation(t *testing.T) { + explanation := "Read README.md first to confirm the current project structure before editing the config example." + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, + }}, + } + + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil) + if got != explanation { + t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want full explanation", got) + } +} + +func TestToolFeedbackArgsPreview_UsesJSONAndTruncates(t *testing.T) { + got := toolFeedbackArgsPreview(map[string]any{ + "path": "README.md", + "limit": 42, + }, 128) + want := "{\n \"limit\": 42,\n \"path\": \"README.md\"\n}" + if got != want { + t.Fatalf("toolFeedbackArgsPreview() = %q, want %q", got, want) + } +} + type picoInterleavedContentProvider struct { calls int } @@ -1999,6 +2087,43 @@ func (m *picoInterleavedContentProvider) GetDefaultModel() string { return "pico-interleaved-content-model" } +type picoDistinctToolCallContentProvider struct { + calls int +} + +func (m *picoDistinctToolCallContentProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + Content: "intermediate model text", + ToolCalls: []providers.ToolCall{{ + ID: "call_tool_limit_test", + Type: "function", + Name: "tool_limit_test_tool", + Arguments: map[string]any{"value": "x"}, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "final model text", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *picoDistinctToolCallContentProvider) GetDefaultModel() string { + return "pico-distinct-tool-call-content-model" +} + type toolLimitOnlyProvider struct{} func (m *toolLimitOnlyProvider) Chat( @@ -3922,6 +4047,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { select { case outbound := <-msgBus.OutboundChan(): + escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`) if outbound.Channel != "telegram" { t.Fatalf("tool feedback channel = %q, want %q", outbound.Channel, "telegram") } @@ -3940,6 +4066,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, escapedHeartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } if strings.Contains(outbound.Content, "Previous turn explanation") { t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) } @@ -3957,6 +4089,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") @@ -4003,6 +4311,7 @@ func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) select { case outbound := <-msgBus.OutboundChan(): + escapedHeartbeatFile := strings.ReplaceAll(heartbeatFile, `\`, `\\`) if !strings.Contains(outbound.Content, "`read_file`") { t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) } @@ -4012,6 +4321,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, escapedHeartbeatFile) { + t.Fatalf("tool feedback content = %q, want tool argument value", outbound.Content) + } if strings.Contains(outbound.Content, "Read README.md first") { t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) } @@ -4143,7 +4458,7 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t } msgBus := bus.NewMessageBus() - provider := &picoInterleavedContentProvider{} + provider := &picoDistinctToolCallContentProvider{} al := NewAgentLoop(cfg, msgBus, provider) agent := al.GetRegistry().GetDefaultAgent() @@ -4169,22 +4484,28 @@ func TestRun_PicoPublishesAssistantContentDuringToolCallsWithoutFinalDuplicate(t t.Fatalf("PublishInbound() error = %v", err) } - outputs := make([]string, 0, 2) + outputs := make([]bus.OutboundMessage, 0, 3) deadline := time.After(2 * time.Second) - for len(outputs) < 2 { + for len(outputs) < 3 { select { case outbound := <-msgBus.OutboundChan(): - outputs = append(outputs, outbound.Content) + outputs = append(outputs, outbound) case <-deadline: t.Fatalf("timed out waiting for pico outputs, got %v", outputs) } } - if outputs[0] != "intermediate model text" { - t.Fatalf("first outbound content = %q, want %q", outputs[0], "intermediate model text") + if outputs[0].Content != "intermediate model text" { + t.Fatalf("first outbound content = %q, want %q", outputs[0].Content, "intermediate model text") } - if outputs[1] != "final model text" { - t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text") + if outputs[1].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls { + t.Fatalf("second outbound = %+v, want tool_calls message", outputs[1]) + } + if !strings.Contains(outputs[1].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") { + t.Fatalf("second outbound tool_calls = %q, want tool name", outputs[1].Context.Raw[metadataKeyToolCalls]) + } + if outputs[2].Content != "final model text" { + t.Fatalf("third outbound content = %q, want %q", outputs[2].Content, "final model text") } runCancel() @@ -4299,22 +4620,28 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi t.Fatalf("PublishInbound() error = %v", err) } - outputs := make([]string, 0, 2) + outputs := make([]bus.OutboundMessage, 0, 3) deadline := time.After(2 * time.Second) for len(outputs) < 2 { select { case outbound := <-msgBus.OutboundChan(): - outputs = append(outputs, outbound.Content) + outputs = append(outputs, outbound) case <-deadline: t.Fatalf("timed out waiting for pico outputs, got %v", outputs) } } - if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text" { - t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0]) + if outputs[0].Context.Raw[metadataKeyMessageKind] != messageKindToolCalls { + t.Fatalf("first outbound = %+v, want tool_calls message", outputs[0]) } - if outputs[1] != "final model text" { - t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text") + if outputs[0].Content != "" { + t.Fatalf("first outbound content = %q, want empty tool_calls content", outputs[0].Content) + } + if !strings.Contains(outputs[0].Context.Raw[metadataKeyToolCalls], "tool_limit_test_tool") { + t.Fatalf("first outbound tool_calls = %q, want tool name", outputs[0].Context.Raw[metadataKeyToolCalls]) + } + if outputs[1].Content != "final model text" { + t.Fatalf("second outbound content = %q, want %q", outputs[1].Content, "final model text") } runCancel() @@ -4334,7 +4661,7 @@ func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testi } } -func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { +func TestResolveMediaRefs_ImageInjectsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -4362,15 +4689,110 @@ func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media)) } - if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { - t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40]) + localPath, _, _ := store.ResolveWithMeta(ref) + expectedContent := "describe this [image:" + localPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } -func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { +func TestResolveMediaRefs_ToolRoleImageAppendedAsUserMessage(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "tool-result.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB + 0x00, 0x00, 0x00, // no interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + } + if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "tool", Content: "Image loaded", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + // Tool message should have path tag but no base64 + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media in tool message, got %d", len(result[0].Media)) + } + localPath, _, _ := store.ResolveWithMeta(ref) + if !strings.Contains(result[0].Content, "[image:"+localPath+"]") { + t.Fatalf("expected image path tag in tool content, got %q", result[0].Content) + } + + // A synthetic user message with base64 should follow + if len(result) != 2 { + t.Fatalf("expected 2 messages (tool + synthetic user), got %d", len(result)) + } + if result[1].Role != "user" { + t.Fatalf("expected synthetic message role=user, got %q", result[1].Role) + } + if len(result[1].Media) != 1 { + t.Fatalf("expected 1 base64 media in synthetic user message, got %d", len(result[1].Media)) + } + if !strings.HasPrefix(result[1].Media[0], "data:image/png;base64,") { + t.Fatalf("expected data:image/png;base64, prefix, got %q", result[1].Media[0][:40]) + } +} + +func TestResolveMediaRefs_MultiToolCallPreservesOrdering(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // Create image for tool #1 + pngPath := filepath.Join(dir, "loaded.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + // Simulate: assistant called load_image + read_file, two tool results follow + messages := []providers.Message{ + {Role: "assistant", Content: "Let me load the image and read the file."}, + {Role: "tool", Content: "Image loaded [image: photo]", Media: []string{imgRef}}, + {Role: "tool", Content: "file contents here"}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + // assistant, tool#1, tool#2 must remain contiguous — no user in between + if result[0].Role != "assistant" { + t.Fatalf("result[0] expected assistant, got %q", result[0].Role) + } + if result[1].Role != "tool" { + t.Fatalf("result[1] expected tool, got %q", result[1].Role) + } + if result[2].Role != "tool" { + t.Fatalf("result[2] expected tool, got %q", result[2].Role) + } + + // Synthetic user message should come AFTER the tool block + if len(result) != 4 { + t.Fatalf("expected 4 messages (assistant + 2 tool + synthetic user), got %d", len(result)) + } + if result[3].Role != "user" { + t.Fatalf("result[3] expected user, got %q", result[3].Role) + } + if len(result[3].Media) != 1 || !strings.HasPrefix(result[3].Media[0], "data:image/png;base64,") { + t.Fatal("expected synthetic user message to contain base64 image") + } +} + +func TestResolveMediaRefs_OversizedImageSkipsBase64KeepsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -4392,6 +4814,11 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { if len(result[0].Media) != 0 { t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) } + localPath, _, _ := store.ResolveWithMeta(ref) + expected := "hi [image:" + localPath + "]" + if result[0].Content != expected { + t.Fatalf("expected content %q, got %q", expected, result[0].Content) + } } func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) { @@ -4469,11 +4896,13 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 media, got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (images use path tags), got %d", len(result[0].Media)) } - if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") { - t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) + localPath, _, _ := store.ResolveWithMeta(ref) + expectedContent := "hi [image:" + localPath + "]" + if result[0].Content != expectedContent { + t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } @@ -4563,6 +4992,98 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) { } } +func TestInjectPathTags_HandlesVariousChannelPlaceholders(t *testing.T) { + cases := []struct { + name string + content string + tag string + want string + }{ + // Telegram / Feishu format + {"image_photo", "[image: photo]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + // WeCom / WeChat / Line format + {"bare_image", "[image]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + // QQ / Discord format with filename + {"image_filename", "[image: pic.jpg]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"}, + {"audio_with_filename", "[audio: voice.m4a]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"}, + {"bare_audio", "[audio]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"}, + {"bare_video", "[video]", "[video:/tmp/v.mp4]", "[video:/tmp/v.mp4]"}, + {"bare_file", "[file]", "[file:/tmp/f.pdf]", "[file:/tmp/f.pdf]"}, + // Mixed surrounding text + { + "with_text", + "hello [image] world", + "[image:/tmp/p.png]", + "hello [image:/tmp/p.png] world", + }, + // No placeholder — append + {"no_placeholder", "hello world", "[image:/tmp/p.png]", "hello world [image:/tmp/p.png]"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := injectPathTags(tc.content, []string{tc.tag}) + if got != tc.want { + t.Errorf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestInjectPathTags_DoesNotReplacePathTag(t *testing.T) { + // If content already contains a path tag, we must not touch it. + content := "see [image:/already/placed.png] thanks" + got := injectPathTags(content, []string{"[image:/new/path.png]"}) + want := "see [image:/already/placed.png] thanks [image:/new/path.png]" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestInjectPathTags_PrependsForJSONContent(t *testing.T) { + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + got := injectPathTags(jsonContent, []string{"[image:/tmp/photo.png]"}) + want := "[image:/tmp/photo.png]\n" + jsonContent + if got != want { + t.Fatalf("expected tag prepended to JSON, got %q", got) + } +} + +func TestInjectPathTags_BracketTextNotTreatedAsJSON(t *testing.T) { + content := "[update] see attached report" + got := injectPathTags(content, []string{"[file:/tmp/report.pdf]"}) + want := "[update] see attached report [file:/tmp/report.pdf]" + if got != want { + t.Fatalf("expected tag appended to bracket text, got %q", got) + } +} + +func TestResolveMediaRefs_JSONContentPrependsPathTag(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + pngPath := filepath.Join(dir, "card_img.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + ref, _ := store.Store(pngPath, media.MediaMeta{ContentType: "image/png"}, "test") + + jsonContent := `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}` + messages := []providers.Message{ + {Role: "user", Content: jsonContent, Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + want := "[image:" + pngPath + "]\n" + jsonContent + if result[0].Content != want { + t.Fatalf("expected path tag prepended to JSON content, got %q", result[0].Content) + } +} + func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() @@ -4606,13 +5127,12 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { } result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if len(result[0].Media) != 1 { - t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media)) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (all types use path tags), got %d", len(result[0].Media)) } - if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { - t.Fatal("expected image to be base64 encoded") - } - expectedContent := "check these [file:" + pdfPath + "]" + imgLocalPath, _, _ := store.ResolveWithMeta(imgRef) + pdfLocalPath, _, _ := store.ResolveWithMeta(fileRef) + expectedContent := "check these [file:" + pdfLocalPath + "] [image:" + imgLocalPath + "]" if result[0].Content != expectedContent { t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index ff98dad68..bbfb3f2ae 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -4,7 +4,9 @@ package agent import ( "context" + "encoding/json" "fmt" + "maps" "path/filepath" "strings" "time" @@ -113,7 +115,6 @@ func latestUserContent(messages []providers.Message) string { func toolFeedbackExplanationFromResponse( response *providers.LLMResponse, messages []providers.Message, - maxLen int, ) string { if response == nil { return "" @@ -125,7 +126,7 @@ func toolFeedbackExplanationFromResponse( if explanation == "" { explanation = toolFeedbackExplanationFromMessages(messages) } - return utils.Truncate(explanation, maxLen) + return explanation } func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string { @@ -144,22 +145,21 @@ func toolFeedbackExplanationForToolCall( response *providers.LLMResponse, toolCall providers.ToolCall, messages []providers.Message, - maxLen int, ) string { if toolCall.ExtraContent != nil { if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" { - return utils.Truncate(explanation, maxLen) + return explanation } } if response == nil { - return utils.Truncate(toolFeedbackExplanationFromMessages(messages), maxLen) + return toolFeedbackExplanationFromMessages(messages) } explanation := strings.TrimSpace(response.Content) if explanation == "" { explanation = toolFeedbackExplanationFromMessages(messages) } - return utils.Truncate(explanation, maxLen) + return explanation } func toolFeedbackExplanationFromMessages(messages []providers.Message) string { @@ -170,6 +170,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 +477,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 } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 1e5a75d92..ecde7c33e 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -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 diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index 687e54532..9cc3e6951 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "reflect" "sort" "sync" "time" @@ -325,6 +326,7 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM switch decision.normalizedAction() { case HookActionContinue, HookActionModify: if next != nil { + next = hm.applyBeforeLLMControls(reg.Name, current, next) current = next } case HookActionAbortTurn, HookActionHardAbort: @@ -367,6 +369,84 @@ func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LL return current, HookDecision{Action: HookActionContinue} } +func (hm *HookManager) applyBeforeLLMControls( + hookName string, + current *LLMHookRequest, + next *LLMHookRequest, +) *LLMHookRequest { + if next == nil || current == nil { + return next + } + if !llmHookSystemMessagesUnchanged(current.Messages, next.Messages) { + logger.WarnCF("hooks", "Hook attempted to modify system prompt; preserving original messages", map[string]any{ + "hook": hookName, + }) + next.Messages = cloneProviderMessages(current.Messages) + } + if !llmHookToolDefinitionsUnchanged(current.Tools, next.Tools) { + logger.WarnCF("hooks", "Hook attempted to modify tool definitions; preserving original tools", map[string]any{ + "hook": hookName, + }) + next.Tools = cloneToolDefinitions(current.Tools) + } + return next +} + +func llmHookSystemMessagesUnchanged(before, after []providers.Message) bool { + beforeSystem := systemMessageFingerprints(before) + afterSystem := systemMessageFingerprints(after) + return reflect.DeepEqual(beforeSystem, afterSystem) +} + +type systemMessageFingerprint struct { + Index int + Message providers.Message +} + +func systemMessageFingerprints(messages []providers.Message) []systemMessageFingerprint { + var fingerprints []systemMessageFingerprint + for i, msg := range messages { + if msg.Role != "system" { + continue + } + msg = providerVisibleMessage(msg) + fingerprints = append(fingerprints, systemMessageFingerprint{ + Index: i, + Message: cloneProviderMessages([]providers.Message{msg})[0], + }) + } + return fingerprints +} + +func llmHookToolDefinitionsUnchanged(before, after []providers.ToolDefinition) bool { + return reflect.DeepEqual(providerVisibleToolDefinitions(before), providerVisibleToolDefinitions(after)) +} + +func providerVisibleMessage(msg providers.Message) providers.Message { + msg.PromptLayer = "" + msg.PromptSlot = "" + msg.PromptSource = "" + if len(msg.SystemParts) > 0 { + msg.SystemParts = append([]providers.ContentBlock(nil), msg.SystemParts...) + for i := range msg.SystemParts { + msg.SystemParts[i].PromptLayer = "" + msg.SystemParts[i].PromptSlot = "" + msg.SystemParts[i].PromptSource = "" + } + } + return msg +} + +func providerVisibleToolDefinitions(defs []providers.ToolDefinition) []providers.ToolDefinition { + cloned := cloneToolDefinitions(defs) + for i := range cloned { + cloned[i].PromptLayer = "" + cloned[i].PromptSlot = "" + cloned[i].PromptSource = "" + } + return cloned +} + func (hm *HookManager) BeforeTool( ctx context.Context, call *ToolCallHookRequest, @@ -788,7 +868,7 @@ func cloneLLMResponse(resp *providers.LLMResponse) *providers.LLMResponse { func cloneStringAnyMap(src map[string]any) map[string]any { if len(src) == 0 { - return nil + return map[string]any{} } cloned := make(map[string]any, len(src)) diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index 1cfa341a7..aa52bf2d5 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -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 { diff --git a/pkg/agent/interfaces/interfaces.go b/pkg/agent/interfaces/interfaces.go index bdf483e20..2efec05e1 100644 --- a/pkg/agent/interfaces/interfaces.go +++ b/pkg/agent/interfaces/interfaces.go @@ -44,4 +44,11 @@ type ChannelManager interface { // SendPlaceholder sends a placeholder message (e.g., for audio transcription). SendPlaceholder(ctx context.Context, channel, chatID string) bool + + // DismissToolFeedback clears any tracked tool feedback animation for the + // given channel/chat. Call this when a turn ends without a final response + // (e.g., ResponseHandled tools) to avoid orphaned animation goroutines. + // outboundCtx carries topic/thread info needed for channels that use + // scoped tracker keys (e.g., Telegram forum topics); may be nil. + DismissToolFeedback(ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext) } diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 48e72e096..f6a8eaad6 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -80,14 +80,18 @@ toolLoop: }, ) - if shouldPublishToolFeedback(al.cfg, ts) { + if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() toolFeedbackExplanation := toolFeedbackExplanationForToolCall( exec.response, tc, messages, - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() @@ -260,7 +264,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) } @@ -357,14 +361,18 @@ toolLoop: }, ) - if shouldPublishToolFeedback(al.cfg, ts) { + if shouldPublishToolFeedback(al.cfg, ts) && ts.channel != "pico" { + toolFeedbackMaxLen := al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength() toolFeedbackExplanation := toolFeedbackExplanationForToolCall( exec.response, tc, messages, - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) + feedbackMsg := utils.FormatToolFeedbackMessage( + toolName, + toolFeedbackExplanation, + toolFeedbackArgsPreview(toolArgs, toolFeedbackMaxLen), + ) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() @@ -631,7 +639,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) } @@ -696,6 +704,9 @@ toolLoop: } ts.setPhase(TurnPhaseCompleted) ts.setFinalContent("") + if al.channelManager != nil && ts.channel != "" { + al.channelManager.DismissToolFeedback(ctx, ts.channel, ts.chatID, ts.opts.InboundContext) + } logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", map[string]any{ "agent_id": ts.agent.ID, diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go index 43d44099a..a2be6f65b 100644 --- a/pkg/agent/pipeline_finalize.go +++ b/pkg/agent/pipeline_finalize.go @@ -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 { diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index 7b3fee208..6bf55fa39 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -319,10 +318,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,11 +381,12 @@ func (p *Pipeline) CallLLM( } } - reasoningContent := exec.response.Reasoning - if reasoningContent == "" { - reasoningContent = exec.response.ReasoningContent - } - if ts.channel == "pico" { + reasoningContent := responseReasoningContent(exec.response) + shouldPublishPicoToolCallInterim := ts.channel == "pico" && len(exec.response.ToolCalls) > 0 + if shouldPublishPicoToolCallInterim { + // Pico tool-call turns publish their reasoning/content/tool summary as a + // structured sequence after the tool-call payload is normalized below. + } else if ts.channel == "pico" { go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) } else { go al.handleReasoning( @@ -424,30 +422,6 @@ func (p *Pipeline) CallLLM( } logger.DebugCF("agent", "LLM response", llmResponseFields) - if al.bus != nil && - ts.channel == "pico" && - len(exec.response.ToolCalls) > 0 && - ts.opts.AllowInterimPicoPublish && - !shouldPublishToolFeedback(al.cfg, ts) { - if strings.TrimSpace(exec.response.Content) != "" { - outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) - publishErr := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: exec.response.Content, - }) - outCancel() - if publishErr != nil { - logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{ - "error": publishErr.Error(), - "channel": ts.channel, - "chat_id": ts.chatID, - "iteration": iteration, - }) - } - } - } - // No-tool-call path: steering check and direct response if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal { responseContent := exec.response.Content @@ -496,7 +470,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) @@ -504,7 +478,6 @@ func (p *Pipeline) CallLLM( exec.response, tc, exec.messages, - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) extraContent := tc.ExtraContent if strings.TrimSpace(toolFeedbackExplanation) != "" { @@ -536,6 +509,15 @@ func (p *Pipeline) CallLLM( ts.recordPersistedMessage(assistantMsg) ts.ingestMessage(turnCtx, al, assistantMsg) } + if shouldPublishPicoToolCallInterim { + al.publishPicoToolCallInterim( + turnCtx, + ts, + reasoningContent, + exec.response.Content, + assistantMsg.ToolCalls, + ) + } return ControlToolLoop, nil } diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go index e6ead1012..219e4e5de 100644 --- a/pkg/agent/pipeline_setup.go +++ b/pkg/agent/pipeline_setup.go @@ -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 { diff --git a/pkg/agent/prompt.go b/pkg/agent/prompt.go new file mode 100644 index 000000000..be5ccddf2 --- /dev/null +++ b/pkg/agent/prompt.go @@ -0,0 +1,496 @@ +package agent + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type PromptLayer string + +const ( + PromptLayerKernel PromptLayer = "kernel" + PromptLayerInstruction PromptLayer = "instruction" + PromptLayerCapability PromptLayer = "capability" + PromptLayerContext PromptLayer = "context" + PromptLayerTurn PromptLayer = "turn" +) + +type PromptSlot string + +const ( + PromptSlotIdentity PromptSlot = "identity" + PromptSlotHierarchy PromptSlot = "hierarchy" + PromptSlotWorkspace PromptSlot = "workspace" + PromptSlotTooling PromptSlot = "tooling" + PromptSlotMCP PromptSlot = "mcp" + PromptSlotSkillCatalog PromptSlot = "skill_catalog" + PromptSlotActiveSkill PromptSlot = "active_skill" + PromptSlotMemory PromptSlot = "memory" + PromptSlotRuntime PromptSlot = "runtime" + PromptSlotSummary PromptSlot = "summary" + PromptSlotMessage PromptSlot = "message" + PromptSlotSteering PromptSlot = "steering" + PromptSlotSubTurn PromptSlot = "subturn" + PromptSlotInterrupt PromptSlot = "interrupt" + PromptSlotOutput PromptSlot = "output" +) + +type PromptSourceID string + +const ( + PromptSourceKernel PromptSourceID = "runtime.kernel" + PromptSourceHierarchy PromptSourceID = "runtime.hierarchy" + PromptSourceWorkspace PromptSourceID = "workspace.definition" + PromptSourceRuntime PromptSourceID = "runtime.context" + PromptSourceSummary PromptSourceID = "context.summary" + PromptSourceMemory PromptSourceID = "memory:workspace" + PromptSourceSkillCatalog PromptSourceID = "skill:index" + PromptSourceActiveSkills PromptSourceID = "skill:active" + PromptSourceToolRegistry PromptSourceID = "tool_registry:native" + PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery" + PromptSourceOutputPolicy PromptSourceID = "runtime.output" + PromptSourceSubTurnProfile PromptSourceID = "subturn.profile" + PromptSourceUserMessage PromptSourceID = "turn:user_message" + PromptSourceSteering PromptSourceID = "turn:steering" + PromptSourceSubTurnResult PromptSourceID = "turn:subturn_result" + PromptSourceInterrupt PromptSourceID = "turn:interrupt" +) + +type PromptCachePolicy string + +const ( + PromptCacheDefault PromptCachePolicy = "" + PromptCacheEphemeral PromptCachePolicy = "ephemeral" + PromptCacheNone PromptCachePolicy = "none" +) + +type PromptPlacement struct { + Layer PromptLayer + Slot PromptSlot +} + +type PromptSourceDescriptor struct { + ID PromptSourceID + Owner string + Description string + Allowed []PromptPlacement + StableByDefault bool +} + +type PromptSource struct { + ID PromptSourceID + Name string + Path string +} + +type PromptPart struct { + ID string + Layer PromptLayer + Slot PromptSlot + Source PromptSource + Title string + Content string + Stable bool + Cache PromptCachePolicy +} + +type PromptBuildRequest struct { + History []providers.Message + Summary string + + CurrentMessage string + Media []string + + Channel string + ChatID string + SenderID string + SenderDisplayName string + + ActiveSkills []string + Overlays []PromptPart +} + +type PromptContributor interface { + PromptSource() PromptSourceDescriptor + ContributePrompt(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) +} + +type PromptRegistry struct { + mu sync.RWMutex + sources map[PromptSourceID]PromptSourceDescriptor + contributors []PromptContributor + warned map[PromptSourceID]struct{} +} + +func NewPromptRegistry() *PromptRegistry { + r := &PromptRegistry{ + sources: make(map[PromptSourceID]PromptSourceDescriptor), + warned: make(map[PromptSourceID]struct{}), + } + for _, desc := range builtinPromptSources() { + if err := r.RegisterSource(desc); err != nil { + logger.WarnCF("agent", "Failed to register builtin prompt source", map[string]any{ + "source": desc.ID, + "error": err.Error(), + }) + } + } + return r +} + +func builtinPromptSources() []PromptSourceDescriptor { + return []PromptSourceDescriptor{ + { + ID: PromptSourceKernel, + Owner: "agent", + Description: "Core picoclaw identity and hard rules", + Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotIdentity}}, + StableByDefault: true, + }, + { + ID: PromptSourceHierarchy, + Owner: "agent", + Description: "Prompt hierarchy rules", + Allowed: []PromptPlacement{{Layer: PromptLayerKernel, Slot: PromptSlotHierarchy}}, + StableByDefault: true, + }, + { + ID: PromptSourceWorkspace, + Owner: "workspace", + Description: "Workspace and agent definition files", + Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}}, + StableByDefault: true, + }, + { + ID: PromptSourceToolDiscovery, + Owner: "tools", + Description: "Tool discovery instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + }, + { + ID: PromptSourceToolRegistry, + Owner: "tools", + Description: "Native provider tool definitions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + }, + { + ID: PromptSourceSkillCatalog, + Owner: "skills", + Description: "Installed skill catalog", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotSkillCatalog}}, + StableByDefault: true, + }, + { + ID: PromptSourceActiveSkills, + Owner: "skills", + Description: "Active skill instructions for the current request", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}}, + StableByDefault: false, + }, + { + ID: PromptSourceMemory, + Owner: "memory", + Description: "Workspace memory context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotMemory}}, + StableByDefault: true, + }, + { + ID: PromptSourceRuntime, + Owner: "agent", + Description: "Per-request runtime context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotRuntime}}, + StableByDefault: false, + }, + { + ID: PromptSourceSummary, + Owner: "context_manager", + Description: "Conversation summary context", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotSummary}}, + StableByDefault: false, + }, + { + ID: PromptSourceOutputPolicy, + Owner: "agent", + Description: "Output formatting policy", + Allowed: []PromptPlacement{{Layer: PromptLayerContext, Slot: PromptSlotOutput}}, + StableByDefault: true, + }, + { + ID: PromptSourceSubTurnProfile, + Owner: "subturn", + Description: "Child agent profile instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerInstruction, Slot: PromptSlotWorkspace}}, + StableByDefault: false, + }, + { + ID: PromptSourceUserMessage, + Owner: "turn", + Description: "Current user message for this turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotMessage}}, + StableByDefault: false, + }, + { + ID: PromptSourceSteering, + Owner: "turn", + Description: "Steering message injected into a running turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSteering}}, + StableByDefault: false, + }, + { + ID: PromptSourceSubTurnResult, + Owner: "turn", + Description: "SubTurn result injected into a parent turn", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotSubTurn}}, + StableByDefault: false, + }, + { + ID: PromptSourceInterrupt, + Owner: "turn", + Description: "Graceful interrupt hint injected into the terminal LLM call", + Allowed: []PromptPlacement{{Layer: PromptLayerTurn, Slot: PromptSlotInterrupt}}, + StableByDefault: false, + }, + } +} + +func (r *PromptRegistry) RegisterSource(desc PromptSourceDescriptor) error { + if r == nil { + return fmt.Errorf("prompt registry is nil") + } + desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID))) + if desc.ID == "" { + return fmt.Errorf("prompt source id is required") + } + if len(desc.Allowed) == 0 { + return fmt.Errorf("prompt source %q must declare at least one placement", desc.ID) + } + + r.mu.Lock() + defer r.mu.Unlock() + r.sources[desc.ID] = clonePromptSourceDescriptor(desc) + return nil +} + +func (r *PromptRegistry) RegisterContributor(contributor PromptContributor) error { + if r == nil { + return fmt.Errorf("prompt registry is nil") + } + if contributor == nil { + return fmt.Errorf("prompt contributor is nil") + } + desc := contributor.PromptSource() + desc.ID = PromptSourceID(strings.TrimSpace(string(desc.ID))) + if err := r.RegisterSource(desc); err != nil { + return err + } + + r.mu.Lock() + defer r.mu.Unlock() + r.contributors = slices.DeleteFunc(r.contributors, func(existing PromptContributor) bool { + return PromptSourceID(strings.TrimSpace(string(existing.PromptSource().ID))) == desc.ID + }) + r.contributors = append(r.contributors, contributor) + return nil +} + +func (r *PromptRegistry) Collect(ctx context.Context, req PromptBuildRequest) ([]PromptPart, error) { + if r == nil { + return nil, nil + } + + r.mu.RLock() + contributors := append([]PromptContributor(nil), r.contributors...) + r.mu.RUnlock() + + var parts []PromptPart + for _, contributor := range contributors { + contributed, err := contributor.ContributePrompt(ctx, req) + if err != nil { + return nil, err + } + for _, part := range contributed { + if err := r.ValidatePart(part); err != nil { + return nil, err + } + parts = append(parts, part) + } + } + return parts, nil +} + +func (r *PromptRegistry) ValidatePart(part PromptPart) error { + if r == nil { + return nil + } + sourceID := PromptSourceID(strings.TrimSpace(string(part.Source.ID))) + if sourceID == "" { + return fmt.Errorf("prompt part %q has empty source id", part.ID) + } + + r.mu.Lock() + defer r.mu.Unlock() + + desc, ok := r.sources[sourceID] + if !ok { + if _, warned := r.warned[sourceID]; !warned { + r.warned[sourceID] = struct{}{} + logger.WarnCF("agent", "Unregistered prompt source allowed in compatibility mode", map[string]any{ + "source": sourceID, + "layer": part.Layer, + "slot": part.Slot, + "part": part.ID, + }) + } + return nil + } + if promptPlacementAllowed(desc.Allowed, PromptPlacement{Layer: part.Layer, Slot: part.Slot}) { + return nil + } + return fmt.Errorf("prompt source %q cannot write to %s/%s", sourceID, part.Layer, part.Slot) +} + +func promptPlacementAllowed(allowed []PromptPlacement, placement PromptPlacement) bool { + return slices.ContainsFunc(allowed, func(candidate PromptPlacement) bool { + return candidate.Layer == placement.Layer && candidate.Slot == placement.Slot + }) +} + +func clonePromptSourceDescriptor(desc PromptSourceDescriptor) PromptSourceDescriptor { + desc.Allowed = append([]PromptPlacement(nil), desc.Allowed...) + return desc +} + +type PromptStack struct { + registry *PromptRegistry + parts []PromptPart + sealed bool +} + +func NewPromptStack(registry *PromptRegistry) *PromptStack { + return &PromptStack{registry: registry} +} + +func (s *PromptStack) Add(part PromptPart) error { + if s == nil { + return fmt.Errorf("prompt stack is nil") + } + if s.sealed { + return fmt.Errorf("prompt stack is sealed") + } + if strings.TrimSpace(part.Content) == "" { + return nil + } + if strings.TrimSpace(part.ID) == "" { + return fmt.Errorf("prompt part id is required") + } + if s.registry != nil { + if err := s.registry.ValidatePart(part); err != nil { + return err + } + } + s.parts = append(s.parts, part) + return nil +} + +func (s *PromptStack) Seal() { + if s != nil { + s.sealed = true + } +} + +func (s *PromptStack) Parts() []PromptPart { + if s == nil || len(s.parts) == 0 { + return nil + } + return append([]PromptPart(nil), s.parts...) +} + +func renderPromptPartsLegacy(parts []PromptPart) string { + textParts := make([]string, 0, len(parts)) + for _, part := range sortPromptParts(parts) { + if strings.TrimSpace(part.Content) == "" { + continue + } + textParts = append(textParts, part.Content) + } + return strings.Join(textParts, "\n\n---\n\n") +} + +func sortPromptParts(parts []PromptPart) []PromptPart { + sorted := append([]PromptPart(nil), parts...) + slices.SortStableFunc(sorted, func(a, b PromptPart) int { + if d := layerPriority(b.Layer) - layerPriority(a.Layer); d != 0 { + return d + } + if d := slotPriority(b.Slot) - slotPriority(a.Slot); d != 0 { + return d + } + if a.Source.ID != b.Source.ID { + return strings.Compare(string(a.Source.ID), string(b.Source.ID)) + } + return strings.Compare(a.ID, b.ID) + }) + return sorted +} + +func layerPriority(layer PromptLayer) int { + switch layer { + case PromptLayerKernel: + return 100 + case PromptLayerInstruction: + return 80 + case PromptLayerCapability: + return 60 + case PromptLayerContext: + return 40 + case PromptLayerTurn: + return 20 + default: + return 0 + } +} + +func slotPriority(slot PromptSlot) int { + switch slot { + case PromptSlotIdentity: + return 1000 + case PromptSlotHierarchy: + return 990 + case PromptSlotWorkspace: + return 900 + case PromptSlotTooling: + return 800 + case PromptSlotMCP: + return 790 + case PromptSlotSkillCatalog: + return 780 + case PromptSlotActiveSkill: + return 770 + case PromptSlotMemory: + return 700 + case PromptSlotOutput: + return 695 + case PromptSlotRuntime: + return 690 + case PromptSlotSummary: + return 680 + case PromptSlotMessage: + return 600 + case PromptSlotSteering: + return 590 + case PromptSlotSubTurn: + return 580 + case PromptSlotInterrupt: + return 570 + default: + return 0 + } +} diff --git a/pkg/agent/prompt_contributors.go b/pkg/agent/prompt_contributors.go new file mode 100644 index 000000000..960572e03 --- /dev/null +++ b/pkg/agent/prompt_contributors.go @@ -0,0 +1,139 @@ +package agent + +import ( + "context" + "fmt" + "strings" +) + +type toolDiscoveryPromptContributor struct { + useBM25 bool + useRegex bool +} + +func (c toolDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { + return PromptSourceDescriptor{ + ID: PromptSourceToolDiscovery, + Owner: "tools", + Description: "Tool discovery instructions", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + StableByDefault: true, + } +} + +func (c toolDiscoveryPromptContributor) ContributePrompt( + _ context.Context, + _ PromptBuildRequest, +) ([]PromptPart, error) { + content := formatToolDiscoveryRule(c.useBM25, c.useRegex) + if strings.TrimSpace(content) == "" { + return nil, nil + } + + return []PromptPart{ + { + ID: "capability.tool_discovery", + Layer: PromptLayerCapability, + Slot: PromptSlotTooling, + Source: PromptSource{ID: PromptSourceToolDiscovery, Name: "tool_registry:discovery"}, + Title: "tool discovery", + Content: content, + Stable: true, + Cache: PromptCacheEphemeral, + }, + }, nil +} + +type mcpServerPromptContributor struct { + serverName string + toolCount int + deferred bool +} + +func (c mcpServerPromptContributor) PromptSource() PromptSourceDescriptor { + return PromptSourceDescriptor{ + ID: mcpPromptSourceID(c.serverName), + Owner: "mcp", + Description: fmt.Sprintf("MCP server %q capability prompt", c.serverName), + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}}, + StableByDefault: true, + } +} + +func (c mcpServerPromptContributor) ContributePrompt( + _ context.Context, + _ PromptBuildRequest, +) ([]PromptPart, error) { + serverName := strings.TrimSpace(c.serverName) + if serverName == "" || c.toolCount <= 0 { + return nil, nil + } + + availability := "available as native tools" + if c.deferred { + availability = "hidden behind tool discovery until unlocked" + } + + return []PromptPart{ + { + ID: "capability.mcp." + promptSourceComponent(serverName), + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: mcpPromptSourceID(serverName), Name: "mcp:" + serverName}, + Title: "MCP server capability", + Content: fmt.Sprintf( + "MCP server `%s` is connected. It contributes %d tool(s), currently %s.", + serverName, + c.toolCount, + availability, + ), + Stable: true, + Cache: PromptCacheEphemeral, + }, + }, nil +} + +func mcpPromptSourceID(serverName string) PromptSourceID { + return PromptSourceID("mcp:" + promptSourceComponent(serverName)) +} + +func promptSourceComponent(value string) string { + const maxLen = 64 + + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "unnamed" + } + + var b strings.Builder + lastWasSep := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + lastWasSep = false + case r >= '0' && r <= '9': + b.WriteRune(r) + lastWasSep = false + case r == '-' || r == '_': + if !lastWasSep && b.Len() > 0 { + b.WriteRune(r) + lastWasSep = true + } + default: + if !lastWasSep && b.Len() > 0 { + b.WriteRune('_') + lastWasSep = true + } + } + } + + result := strings.Trim(b.String(), "_") + if result == "" { + return "unnamed" + } + if len(result) > maxLen { + return result[:maxLen] + } + return result +} diff --git a/pkg/agent/prompt_test.go b/pkg/agent/prompt_test.go new file mode 100644 index 000000000..b76b0040d --- /dev/null +++ b/pkg/agent/prompt_test.go @@ -0,0 +1,275 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestPromptRegistry_RejectsRegisteredSourceWrongPlacement(t *testing.T) { + registry := NewPromptRegistry() + if err := registry.RegisterSource(PromptSourceDescriptor{ + ID: "test:source", + Owner: "test", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}}, + }); err != nil { + t.Fatalf("RegisterSource() error = %v", err) + } + + err := registry.ValidatePart(PromptPart{ + ID: "wrong.placement", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: "test:source"}, + Content: "runtime text", + }) + if err == nil { + t.Fatal("ValidatePart() error = nil, want placement error") + } +} + +func TestPromptRegistry_AllowsUnregisteredSourceInCompatibilityMode(t *testing.T) { + registry := NewPromptRegistry() + + err := registry.ValidatePart(PromptPart{ + ID: "unregistered.part", + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: "mcp:dynamic-server"}, + Content: "dynamic MCP prompt", + }) + if err != nil { + t.Fatalf("ValidatePart() error = %v, want nil for unregistered source", err) + } +} + +func TestRenderPromptPartsLegacy_UsesLayerAndSlotOrder(t *testing.T) { + parts := []PromptPart{ + { + ID: "context.runtime", + Layer: PromptLayerContext, + Slot: PromptSlotRuntime, + Source: PromptSource{ID: PromptSourceRuntime}, + Content: "runtime", + }, + { + ID: "kernel.identity", + Layer: PromptLayerKernel, + Slot: PromptSlotIdentity, + Source: PromptSource{ID: PromptSourceKernel}, + Content: "kernel", + }, + { + ID: "capability.skill", + Layer: PromptLayerCapability, + Slot: PromptSlotActiveSkill, + Source: PromptSource{ID: "skill:test"}, + Content: "skill", + }, + { + ID: "instruction.workspace", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceWorkspace}, + Content: "workspace", + }, + } + + got := renderPromptPartsLegacy(parts) + want := strings.Join([]string{"kernel", "workspace", "skill", "runtime"}, "\n\n---\n\n") + if got != want { + t.Fatalf("renderPromptPartsLegacy() = %q, want %q", got, want) + } +} + +func TestBuildMessagesFromPrompt_IncludesSystemPromptOverlay(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "do child task", + Overlays: promptOverlaysForOptions(processOptions{ + SystemPromptOverride: "Use child-only system instructions.", + }), + }) + + if len(messages) < 2 { + t.Fatalf("messages len = %d, want at least 2", len(messages)) + } + if messages[0].Role != "system" { + t.Fatalf("messages[0].Role = %q, want system", messages[0].Role) + } + if !strings.Contains(messages[0].Content, "Use child-only system instructions.") { + t.Fatalf("system prompt missing overlay: %q", messages[0].Content) + } + if messages[1].Role != "user" || messages[1].Content != "do child task" { + t.Fatalf("messages[1] = %#v, want user task", messages[1]) + } +} + +func TestBuildMessagesFromPrompt_AttachesInternalPromptMetadata(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{ + CurrentMessage: "hello", + Summary: "prior context", + }) + if len(messages) != 2 { + t.Fatalf("messages len = %d, want 2", len(messages)) + } + + system := messages[0] + if len(system.SystemParts) < 3 { + t.Fatalf("system parts len = %d, want at least 3", len(system.SystemParts)) + } + if system.SystemParts[0].PromptLayer != string(PromptLayerKernel) || + system.SystemParts[0].PromptSlot != string(PromptSlotIdentity) || + system.SystemParts[0].PromptSource != string(PromptSourceKernel) { + t.Fatalf("static system metadata = %#v, want kernel identity", system.SystemParts[0]) + } + + var hasRuntime, hasSummary bool + for _, part := range system.SystemParts { + switch part.PromptSource { + case string(PromptSourceRuntime): + hasRuntime = true + if part.CacheControl != nil { + t.Fatalf("runtime cache control = %#v, want nil", part.CacheControl) + } + case string(PromptSourceSummary): + hasSummary = true + if part.CacheControl != nil { + t.Fatalf("summary cache control = %#v, want nil", part.CacheControl) + } + } + } + if !hasRuntime { + t.Fatal("system parts missing runtime prompt metadata") + } + if !hasSummary { + t.Fatal("system parts missing summary prompt metadata") + } + + user := messages[1] + if user.PromptLayer != string(PromptLayerTurn) || + user.PromptSlot != string(PromptSlotMessage) || + user.PromptSource != string(PromptSourceUserMessage) { + t.Fatalf("user message metadata = %#v, want turn message", user) + } + + data, err := json.Marshal(messages) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if strings.Contains(string(data), "PromptSource") || + strings.Contains(string(data), "PromptLayer") || + strings.Contains(string(data), "PromptSlot") { + t.Fatalf("internal prompt metadata leaked into JSON: %s", data) + } +} + +func TestContextBuilder_CollectsToolDiscoveryContributor(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()).WithToolDiscovery(true, false) + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + system := messages[0] + if !strings.Contains(system.Content, "tool_search_tool_bm25") { + t.Fatalf("system prompt missing tool discovery rule: %q", system.Content) + } + + var found bool + for _, part := range system.SystemParts { + if part.PromptSource == string(PromptSourceToolDiscovery) { + found = true + if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotTooling) { + t.Fatalf("tool discovery metadata = %#v, want capability/tooling", part) + } + if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" { + t.Fatalf("tool discovery cache control = %#v, want ephemeral", part.CacheControl) + } + } + } + if !found { + t.Fatal("system parts missing tool discovery prompt metadata") + } +} + +func TestContextBuilder_CollectsMCPServerContributor(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + err := cb.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: "GitHub Server", + toolCount: 3, + deferred: true, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + system := messages[0] + if !strings.Contains(system.Content, "MCP server `GitHub Server` is connected") { + t.Fatalf("system prompt missing MCP contributor content: %q", system.Content) + } + + var found bool + for _, part := range system.SystemParts { + if part.PromptSource == "mcp:github_server" { + found = true + if part.PromptLayer != string(PromptLayerCapability) || part.PromptSlot != string(PromptSlotMCP) { + t.Fatalf("mcp metadata = %#v, want capability/mcp", part) + } + if part.CacheControl == nil || part.CacheControl.Type != "ephemeral" { + t.Fatalf("mcp cache control = %#v, want ephemeral", part.CacheControl) + } + } + } + if !found { + t.Fatal("system parts missing MCP prompt metadata") + } +} + +type testPromptContributor struct { + desc PromptSourceDescriptor + part PromptPart +} + +func (c testPromptContributor) PromptSource() PromptSourceDescriptor { + return c.desc +} + +func (c testPromptContributor) ContributePrompt(_ context.Context, _ PromptBuildRequest) ([]PromptPart, error) { + return []PromptPart{c.part}, nil +} + +func TestContextBuilder_CollectsRegisteredPromptContributors(t *testing.T) { + t.Setenv("PICOCLAW_BUILTIN_SKILLS", t.TempDir()) + cb := NewContextBuilder(t.TempDir()) + + sourceID := PromptSourceID("test:contributor") + err := cb.RegisterPromptContributor(testPromptContributor{ + desc: PromptSourceDescriptor{ + ID: sourceID, + Owner: "test", + Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotMCP}}, + }, + part: PromptPart{ + ID: "capability.mcp.test", + Layer: PromptLayerCapability, + Slot: PromptSlotMCP, + Source: PromptSource{ID: sourceID, Name: "test"}, + Content: "registered contributor prompt", + }, + }) + if err != nil { + t.Fatalf("RegisterPromptContributor() error = %v", err) + } + + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + if !strings.Contains(messages[0].Content, "registered contributor prompt") { + t.Fatalf("system prompt missing contributor content: %q", messages[0].Content) + } +} diff --git a/pkg/agent/prompt_turn.go b/pkg/agent/prompt_turn.go new file mode 100644 index 000000000..588a8f00f --- /dev/null +++ b/pkg/agent/prompt_turn.go @@ -0,0 +1,129 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func promptBuildRequestForTurn( + ts *turnState, + history []providers.Message, + summary string, + currentMessage string, + media []string, +) PromptBuildRequest { + return PromptBuildRequest{ + History: history, + Summary: summary, + CurrentMessage: currentMessage, + Media: append([]string(nil), media...), + Channel: ts.channel, + ChatID: ts.chatID, + SenderID: ts.opts.Dispatch.SenderID(), + SenderDisplayName: ts.opts.SenderDisplayName, + ActiveSkills: activeSkillNames(ts.agent, ts.opts), + Overlays: promptOverlaysForOptions(ts.opts), + } +} + +func promptOverlaysForOptions(opts processOptions) []PromptPart { + systemPrompt := strings.TrimSpace(opts.SystemPromptOverride) + if systemPrompt == "" { + return nil + } + + return []PromptPart{ + { + ID: "instruction.subturn_profile", + Layer: PromptLayerInstruction, + Slot: PromptSlotWorkspace, + Source: PromptSource{ID: PromptSourceSubTurnProfile, Name: "subturn.profile"}, + Title: "SubTurn System Instructions", + Content: systemPrompt, + Stable: false, + Cache: PromptCacheNone, + }, + } +} + +func promptContentBlock(part PromptPart, cache *providers.CacheControl) providers.ContentBlock { + if cache == nil { + cache = cacheControlForPromptPart(part) + } + return providers.ContentBlock{ + Type: "text", + Text: part.Content, + CacheControl: cache, + PromptLayer: string(part.Layer), + PromptSlot: string(part.Slot), + PromptSource: string(part.Source.ID), + } +} + +func cacheControlForPromptPart(part PromptPart) *providers.CacheControl { + switch part.Cache { + case PromptCacheEphemeral: + return &providers.CacheControl{Type: "ephemeral"} + default: + return nil + } +} + +func promptMessageWithMetadata( + msg providers.Message, + layer PromptLayer, + slot PromptSlot, + source PromptSourceID, +) providers.Message { + msg.PromptLayer = string(layer) + msg.PromptSlot = string(slot) + msg.PromptSource = string(source) + return msg +} + +func promptMessageWithDefaultMetadata( + msg providers.Message, + layer PromptLayer, + slot PromptSlot, + source PromptSourceID, +) providers.Message { + if strings.TrimSpace(msg.PromptSource) != "" { + return msg + } + return promptMessageWithMetadata(msg, layer, slot, source) +} + +func userPromptMessage(content string, media []string) providers.Message { + msg := providers.Message{ + Role: "user", + Content: content, + } + if len(media) > 0 { + msg.Media = append([]string(nil), media...) + } + return promptMessageWithMetadata(msg, PromptLayerTurn, PromptSlotMessage, PromptSourceUserMessage) +} + +func steeringPromptMessage(msg providers.Message) providers.Message { + return promptMessageWithDefaultMetadata(msg, PromptLayerTurn, PromptSlotSteering, PromptSourceSteering) +} + +func subTurnResultPromptMessage(content string) providers.Message { + return promptMessageWithMetadata( + providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)}, + PromptLayerTurn, + PromptSlotSubTurn, + PromptSourceSubTurnResult, + ) +} + +func interruptPromptMessage(content string) providers.Message { + return promptMessageWithMetadata( + providers.Message{Role: "user", Content: content}, + PromptLayerTurn, + PromptSlotInterrupt, + PromptSourceInterrupt, + ) +} diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index bff01fbf8..2efa7bbf4 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -187,6 +187,7 @@ func (al *AgentLoop) enqueueSteeringMessage(scope, agentID string, msg providers return fmt.Errorf("steering queue is not initialized") } + msg = steeringPromptMessage(msg) if err := al.steering.pushScope(scope, msg); err != nil { logger.WarnCF("agent", "Failed to enqueue steering message", map[string]any{ "error": err.Error(), diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index bba988672..1ff699976 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -1051,16 +1051,16 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { foundResolvedMedia := false for _, msg := range msgs { - if msg.Role != "user" || msg.Content != "describe this image" || len(msg.Media) != 1 { + if msg.Role != "user" || !strings.Contains(msg.Content, "describe this image") { continue } - if strings.HasPrefix(msg.Media[0], "data:image/png;base64,") { + if strings.Contains(msg.Content, "[image:") { foundResolvedMedia = true break } } if !foundResolvedMedia { - t.Fatal("expected continue path to inject steering media into the provider request") + t.Fatal("expected continue path to inject image path tag into the provider request") } defaultAgent := al.registry.GetDefaultAgent() diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index a65467dbb..4d824bd3a 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -10,6 +10,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -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() diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index 4c8335933..ade2b7c21 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -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: diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 8b5fd4e2c..360c3b7d5 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -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) } // ============================================================================= diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index 81238460a..95579df09 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -64,6 +64,62 @@ func extractJSONStringField(content, field string) string { // Format: {"image_key": "img_xxx"} func extractImageKey(content string) string { return extractJSONStringField(content, "image_key") } +// extractPostImageKeys extracts all image_key values from a Feishu post (rich text) +// message. Post messages have nested arrays of elements where images appear as +// {"tag":"img","image_key":"img_xxx"}. +func extractPostImageKeys(rawContent string) []string { + if rawContent == "" { + return nil + } + + var post map[string]json.RawMessage + if err := json.Unmarshal([]byte(rawContent), &post); err != nil { + return nil + } + + var keys []string + seen := make(map[string]struct{}) + + collectFromRows := func(contentRaw json.RawMessage) { + var rows [][]map[string]any + if err := json.Unmarshal(contentRaw, &rows); err != nil { + return + } + for _, row := range rows { + for _, elem := range row { + if tag, _ := elem["tag"].(string); tag == "img" { + if ik, _ := elem["image_key"].(string); ik != "" { + if _, dup := seen[ik]; !dup { + seen[ik] = struct{}{} + keys = append(keys, ik) + } + } + } + } + } + } + + // Flat format: {"title":"...", "content":[[...]]} + if contentRaw, ok := post["content"]; ok { + collectFromRows(contentRaw) + } + + // Localized format: {"zh_cn": {"title":"...", "content":[[...]]}, ...} + for _, raw := range post { + var locale map[string]json.RawMessage + if err := json.Unmarshal(raw, &locale); err != nil { + continue + } + contentRaw, ok := locale["content"] + if !ok { + continue + } + collectFromRows(contentRaw) + } + + return keys +} + // extractFileKey extracts the file_key from a Feishu file/audio message content JSON. // Format: {"file_key": "file_xxx", "file_name": "...", ...} func extractFileKey(content string) string { return extractJSONStringField(content, "file_key") } diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index ff4af0148..dcf7861a2 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -291,6 +291,100 @@ func TestStripMentionPlaceholders(t *testing.T) { } } +func TestExtractPostImageKeys(t *testing.T) { + tests := []struct { + name string + content string + want []string + }{ + { + name: "empty content", + content: "", + want: nil, + }, + { + name: "invalid JSON", + content: "not json", + want: nil, + }, + { + name: "post with no images", + content: `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"hello"}]]}}`, + want: nil, + }, + { + name: "post with one image", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_v3_001"}]]}}`, + want: []string{"img_v3_001"}, + }, + { + name: "post with multiple images", + content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"see"},{"tag":"img","image_key":"img_001"}],[{"tag":"img","image_key":"img_002"}]]}}`, + want: []string{"img_001", "img_002"}, + }, + { + name: "post with text and image mixed in row", + content: `{"zh_cn":{"title":"","content":[[{"tag":"text","text":"hi"},{"tag":"img","image_key":"img_mix"}]]}}`, + want: []string{"img_mix"}, + }, + { + name: "en_us locale", + content: `{"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`, + want: []string{"img_en"}, + }, + { + name: "multiple locales with distinct images", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_zh"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_en"}]]}}`, + want: []string{"img_zh", "img_en"}, + }, + { + name: "duplicate image_key across locales is deduplicated", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]},"en_us":{"title":"","content":[[{"tag":"img","image_key":"img_same"}]]}}`, + want: []string{"img_same"}, + }, + { + name: "image with empty image_key", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":""}]]}}`, + want: nil, + }, + { + name: "flat format without locale wrapper", + content: `{"title":"","content":[[{"tag":"img","image_key":"img_v3_flat","width":1826,"height":338}],[{"tag":"text","text":" check this image","style":[]}]]}`, + want: []string{"img_v3_flat"}, + }, + { + name: "flat format multiple images", + content: `{"title":"","content":[[{"tag":"img","image_key":"img_flat_1"}],[{"tag":"img","image_key":"img_flat_2"},{"tag":"text","text":"desc"}]]}`, + want: []string{"img_flat_1", "img_flat_2"}, + }, + { + name: "flat format no images", + content: `{"title":"Test","content":[[{"tag":"text","text":"just text"}]]}`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPostImageKeys(tt.content) + if len(got) != len(tt.want) { + t.Errorf("extractPostImageKeys() = %v, want %v", got, tt.want) + return + } + // Use set comparison to avoid map iteration order dependency + gotSet := make(map[string]bool, len(got)) + for _, v := range got { + gotSet[v] = true + } + for _, v := range tt.want { + if !gotSet[v] { + t.Errorf("extractPostImageKeys() missing expected key %q; got %v", v, got) + } + } + }) + } +} + func TestExtractCardImageKeys(t *testing.T) { tests := []struct { name string diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 8f3ae39d9..d09c021c7 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -803,6 +803,14 @@ func (c *FeishuChannel) downloadInboundMedia( refs = append(refs, ref) } + case larkim.MsgTypePost: + for _, imageKey := range extractPostImageKeys(rawContent) { + ref := c.downloadResource(ctx, messageID, imageKey, "image", ".jpg", store, scope) + if ref != "" { + refs = append(refs, ref) + } + } + case larkim.MsgTypeInteractive: // Extract and download images embedded in interactive cards feishuKeys, _ := extractCardImageKeys(rawContent) @@ -842,12 +850,41 @@ func (c *FeishuChannel) downloadInboundMedia( // downloadResource downloads a message resource (image/file) from Feishu, // writes it to the project media directory, and stores the reference in MediaStore. // fallbackExt (e.g. ".jpg") is appended when the resolved filename has no extension. +// +// For image resources, if the primary MessageResource.Get API fails (which +// requires im:message or im:message:readonly scope), a fallback to the +// Image.Get API (which requires im:resource scope) is attempted. This ensures +// image downloads succeed regardless of which permission the user has granted. func (c *FeishuChannel) downloadResource( ctx context.Context, messageID, fileKey, resourceType, fallbackExt string, store media.MediaStore, scope string, ) string { + file, filename := c.fetchResourceData(ctx, messageID, fileKey, resourceType) + if file == nil { + return "" + } + if closer, ok := file.(io.Closer); ok { + defer closer.Close() + } + + if filename == "" { + filename = fileKey + } + if filepath.Ext(filename) == "" && fallbackExt != "" { + filename += fallbackExt + } + + return c.storeResourceFile(ctx, messageID, fileKey, filename, file, store, scope) +} + +// fetchResourceData tries to download a resource from Feishu, first via +// MessageResource.Get, then falling back to Image.Get for image resources. +func (c *FeishuChannel) fetchResourceData( + ctx context.Context, + messageID, fileKey, resourceType string, +) (io.Reader, string) { req := larkim.NewGetMessageResourceReqBuilder(). MessageId(messageID). FileKey(fileKey). @@ -855,41 +892,80 @@ func (c *FeishuChannel) downloadResource( Build() resp, err := c.client.Im.V1.MessageResource.Get(ctx, req) + if err == nil && resp.Success() && resp.File != nil { + return resp.File, resp.FileName + } + if err != nil { - logger.ErrorCF("feishu", "Failed to download resource", map[string]any{ + logger.WarnCF("feishu", "MessageResource.Get failed", map[string]any{ "message_id": messageID, "file_key": fileKey, "error": err.Error(), }) - return "" + } else if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + logger.WarnCF("feishu", "MessageResource.Get api error", map[string]any{ + "message_id": messageID, + "file_key": fileKey, + "code": resp.Code, + "msg": resp.Msg, + }) + } else { + logger.WarnCF("feishu", "MessageResource.Get returned empty file body", map[string]any{ + "message_id": messageID, + "file_key": fileKey, + }) + } + + if resourceType != "image" { + return nil, "" + } + + return c.fetchImageDirect(ctx, fileKey) +} + +// fetchImageDirect downloads an image using the Image.Get API +// (/open-apis/im/v1/images/:image_key), which requires the im:resource scope. +func (c *FeishuChannel) fetchImageDirect(ctx context.Context, imageKey string) (io.Reader, string) { + req := larkim.NewGetImageReqBuilder(). + ImageKey(imageKey). + Build() + + resp, err := c.client.Im.V1.Image.Get(ctx, req) + if err != nil { + logger.ErrorCF("feishu", "Image.Get fallback failed", map[string]any{ + "image_key": imageKey, + "error": err.Error(), + }) + return nil, "" } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) - logger.ErrorCF("feishu", "Resource download api error", map[string]any{ - "code": resp.Code, - "msg": resp.Msg, + logger.ErrorCF("feishu", "Image.Get fallback api error", map[string]any{ + "image_key": imageKey, + "code": resp.Code, + "msg": resp.Msg, }) - return "" + return nil, "" } - if resp.File == nil { - return "" - } - // Safely close the underlying reader if it implements io.Closer (e.g. HTTP response body). - if closer, ok := resp.File.(io.Closer); ok { - defer closer.Close() + return nil, "" } - filename := resp.FileName - if filename == "" { - filename = fileKey - } - // If filename still has no extension, append the fallback (like Telegram's ext parameter). - if filepath.Ext(filename) == "" && fallbackExt != "" { - filename += fallbackExt - } + logger.DebugCF("feishu", "Image downloaded via Image.Get fallback", map[string]any{ + "image_key": imageKey, + }) + return resp.File, resp.FileName +} - // Write to the shared picoclaw_media directory using a unique name to avoid collisions. +// storeResourceFile writes downloaded resource data to disk and registers it in the MediaStore. +func (c *FeishuChannel) storeResourceFile( + ctx context.Context, + messageID, fileKey, filename string, + file io.Reader, + store media.MediaStore, + scope string, +) string { mediaDir := media.TempDir() if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil { logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{ @@ -908,7 +984,7 @@ func (c *FeishuChannel) downloadResource( return "" } - if _, copyErr := io.Copy(out, resp.File); copyErr != nil { + if _, copyErr := io.Copy(out, file); copyErr != nil { out.Close() os.Remove(localPath) logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ @@ -943,8 +1019,8 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { return content } - // Don't append tags to JSON content (interactive cards) - would produce invalid JSON - if messageType == larkim.MsgTypeInteractive { + // Don't append tags to JSON content - would produce invalid JSON + if messageType == larkim.MsgTypeInteractive || messageType == larkim.MsgTypePost { return content } diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 48fdf0f74..d256325ad 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -180,6 +180,13 @@ func TestAppendMediaTags(t *testing.T) { mediaRefs: []string{"ref1"}, want: `{"schema":"2.0","body":{"elements":[{"tag":"img","img_key":"img_123"}]}}`, }, + { + name: "post message with images returns content unchanged", + content: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`, + messageType: "post", + mediaRefs: []string{"ref1"}, + want: `{"zh_cn":{"title":"","content":[[{"tag":"img","image_key":"img_001"}]]}}`, + }, } for _, tt := range tests { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 2ffb1bb10..c6dcfebe3 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -134,6 +134,14 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") } +func outboundMessageBypassesPlaceholderEdit(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + kind := strings.TrimSpace(msg.Context.Raw["message_kind"]) + return strings.EqualFold(kind, "thought") || strings.EqualFold(kind, "tool_calls") +} + func outboundMediaChannel(msg bus.OutboundMediaMessage) string { return msg.Context.Channel } @@ -170,6 +178,35 @@ 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) + } +} + +// DismissToolFeedback clears any tracked tool feedback animation for the +// given channel/chat. This is called when a turn ends without a final +// response (e.g., ResponseHandled tools) to stop orphaned animation goroutines. +// outboundCtx carries topic/thread info for channels that use scoped tracker +// keys (e.g., Telegram forum topics); may be nil for non-topic channels. +func (m *Manager) DismissToolFeedback( + ctx context.Context, channelName, chatID string, outboundCtx *bus.InboundContext, +) { + ch, ok := m.GetChannel(channelName) + if !ok { + return + } + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, outboundCtx) +} + func prepareToolFeedbackMessageContent(ch Channel, content string) string { prepared := strings.TrimSpace(content) if prepared == "" { @@ -183,6 +220,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 +308,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 +333,34 @@ 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 outboundMessageBypassesPlaceholderEdit(msg) { + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } + return nil, false + } if editor, ok := ch.(MessageEditor); ok { content := msg.Content trackedContent := msg.Content @@ -345,6 +410,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 +477,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 diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 273c90468..6c518780d 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -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,202 @@ 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_ThoughtPlaceholderDeleteAndSkipsEdit(t *testing.T) { + m := newTestManager() + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected thought message to bypass placeholder edit") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "thinking trace", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "thought", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf( + "expected thought message to fall through so the channel can send a structured message, got %v", + msgIDs, + ) + } + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if _, ok := m.placeholders.Load("test:123"); ok { + t.Fatal("expected placeholder to be consumed before structured thought send") + } +} + +func TestSendWithRetry_ToolCallsPlaceholderDeleteAndFallsThroughToSend(t *testing.T) { + m := newTestManager() + + ch := &mockDeletingMessageEditor{ + mockMessageEditor: mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + if got := msg.Context.Raw["message_kind"]; got != "tool_calls" { + t.Fatalf("expected tool_calls message kind, got %q", got) + } + if msg.Content != "" { + t.Fatalf("expected empty tool_calls content, got %q", msg.Content) + } + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("expected tool_calls message to bypass placeholder edit") + return nil + }, + }, + } + + m.RecordPlaceholder("test", "123", "456") + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_calls", + "tool_calls": `[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Looking up config"}}]`, + }, + }, + }) + + m.sendWithRetry(context.Background(), "test", w, msg) + + if ch.deleteCalls != 1 { + t.Fatalf("expected placeholder deletion, got %d delete calls", ch.deleteCalls) + } + if ch.deletedChatID != "123" || ch.deletedMessageID != "456" { + t.Fatalf("unexpected placeholder deletion target: %s/%s", ch.deletedChatID, ch.deletedMessageID) + } + if len(ch.sentMessages) != 1 { + t.Fatalf("expected structured tool_calls message to be sent once, got %d", len(ch.sentMessages)) + } +} + +func TestPreSend_NonToolFeedbackSeparateMessagesClearsTrackedMessageWithoutDismiss(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + SeparateMessages: true, + }, + }, + }, + } + + ch := &mockMessageEditor{} + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatal("expected preSend to leave final delivery to the channel") + } + if ch.clearedChatID != "123" { + t.Fatalf("expected tracked tool feedback state to be cleared, got %q", ch.clearedChatID) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback message to be preserved, got dismissal for %q", ch.dismissedChatID) + } + if ch.finalizeCalled { + t.Fatal("expected separate message mode to skip in-place finalization") + } +} + func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) { m := newTestManager() m.streamActive.Store("test:123", true) @@ -1153,6 +1363,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 +1474,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{ diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 5ee028bae..2b167e457 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -333,18 +333,33 @@ func TestIsThoughtPayload(t *testing.T) { want bool }{ { - name: "explicit thought bool", + name: "explicit thought kind", + payload: map[string]any{PayloadKeyKind: MessageKindThought}, + want: true, + }, + { + name: "thought kind ignores case and whitespace", + payload: map[string]any{PayloadKeyKind: " ThOuGhT "}, + want: true, + }, + { + name: "legacy thought bool remains supported for inbound compatibility", payload: map[string]any{PayloadKeyThought: true}, want: true, }, { - name: "thought false", + name: "legacy thought false", payload: map[string]any{PayloadKeyThought: false}, want: false, }, { - name: "thought string ignored", - payload: map[string]any{PayloadKeyThought: "true"}, + name: "tool calls kind", + payload: map[string]any{PayloadKeyKind: MessageKindToolCalls}, + want: false, + }, + { + name: "non-string kind ignored", + payload: map[string]any{PayloadKeyKind: true}, want: false, }, { @@ -380,7 +395,7 @@ func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) { Type: TypeMessageCreate, Payload: map[string]any{ PayloadKeyContent: "internal reasoning", - PayloadKeyThought: true, + PayloadKeyKind: MessageKindThought, }, }) @@ -390,3 +405,31 @@ func TestPicoClientChannel_HandleServerMessage_IgnoresThought(t *testing.T) { case <-time.After(150 * time.Millisecond): } } + +func TestPicoClientChannel_HandleServerMessage_IgnoresLegacyThoughtBool(t *testing.T) { + mb := bus.NewMessageBus() + bc := &config.Channel{Type: config.ChannelPicoClient, Enabled: true} + ch, err := NewPicoClientChannel(bc, &config.PicoClientSettings{ + URL: "ws://localhost:8080/ws", + }, mb) + if err != nil { + t.Fatalf("NewPicoClientChannel() error = %v", err) + } + + ch.ctx = context.Background() + pc := &picoConn{sessionID: "sess-thought-legacy"} + + ch.handleServerMessage(pc, PicoMessage{ + Type: TypeMessageCreate, + Payload: map[string]any{ + PayloadKeyContent: "legacy internal reasoning", + PayloadKeyThought: true, + }, + }) + + select { + case msg := <-mb.InboundChan(): + t.Fatalf("expected no inbound publish for legacy thought payload, got %+v", msg) + case <-time.After(150 * time.Millisecond): + } +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 31360b3de..d1de8f4d5 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -23,6 +23,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) // picoConn represents a single WebSocket connection. @@ -57,8 +58,17 @@ func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") } +func outboundMessageIsToolCalls(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindToolCalls) +} + func outboundMessageFinalizesTrackedToolFeedback(msg bus.OutboundMessage) bool { - return !outboundMessageIsToolFeedback(msg) && !outboundMessageIsThought(msg) + return !outboundMessageIsToolFeedback(msg) && + !outboundMessageIsThought(msg) && + !outboundMessageIsToolCalls(msg) } // writeJSON sends a JSON message to the connection with write locking. @@ -289,6 +299,7 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri } isThought := outboundMessageIsThought(msg) isToolFeedback := outboundMessageIsToolFeedback(msg) + isToolCalls := outboundMessageIsToolCalls(msg) if isToolFeedback { if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { if err != nil { @@ -312,9 +323,23 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri payload := map[string]any{ PayloadKeyContent: content, - PayloadKeyThought: isThought, "message_id": msgID, } + switch { + case isThought: + payload[PayloadKeyKind] = MessageKindThought + + // This field is kept solely for compatibility with legacy pico clients that + // do not yet support the newer "kind" field. + // DO NOT use it for any purpose other than legacy client compatibility. + payload[PayloadKeyThought] = true + + case isToolCalls: + payload[PayloadKeyKind] = MessageKindToolCalls + if toolCalls, ok := picoToolCallsPayload(msg); ok { + payload[PayloadKeyToolCalls] = toolCalls + } + } setContextUsagePayload(payload, msg.ContextUsage) outMsg := newMessage(TypeMessageCreate, payload) @@ -440,7 +465,6 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ PayloadKeyContent: text, - PayloadKeyThought: false, "message_id": msgID, }) @@ -1070,6 +1094,19 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) { } } +func picoToolCallsPayload(msg bus.OutboundMessage) ([]utils.VisibleToolCall, bool) { + raw := strings.TrimSpace(msg.Context.Raw[PayloadKeyToolCalls]) + if raw == "" { + return nil, false + } + + var toolCalls []utils.VisibleToolCall + if err := json.Unmarshal([]byte(raw), &toolCalls); err != nil || len(toolCalls) == 0 { + return nil, false + } + return toolCalls, true +} + func (c *PicoChannel) editMessage( ctx context.Context, chatID string, diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 22ed5451a..bbe73a222 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -131,8 +131,8 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { if got := payload[PayloadKeyContent]; got != "thinking trace" { t.Fatalf("thought content = %#v, want %q", got, "thinking trace") } - if got := payload[PayloadKeyThought]; got != true { - t.Fatalf("thought flag = %#v, want true", got) + if got := payload[PayloadKeyKind]; got != MessageKindThought { + t.Fatalf("thought kind = %#v, want %q", got, MessageKindThought) } if got := payload["message_id"]; got == "msg-progress" || got == nil || got == "" { t.Fatalf("thought message_id = %#v, want new non-progress id", got) @@ -193,6 +193,47 @@ func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { } } +func TestSendPlaceholder_EmitsNormalMessageWithoutKind(t *testing.T) { + ch := newTestPicoChannel(t) + ch.bc.Placeholder.Enabled = true + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + msgID, err := ch.SendPlaceholder(context.Background(), "pico:sess-1") + if err != nil { + t.Fatalf("SendPlaceholder() error = %v", err) + } + if msgID == "" { + t.Fatal("expected placeholder message id") + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("placeholder message type = %q, want %q", msg.Type, TypeMessageCreate) + } + payload := msg.Payload + if got := payload["message_id"]; got != msgID { + t.Fatalf("placeholder message_id = %#v, want %q", got, msgID) + } + if got := payload[PayloadKeyContent]; got != "Thinking..." { + t.Fatalf("placeholder content = %#v, want %q", got, "Thinking...") + } + if got, ok := payload[PayloadKeyKind]; ok { + t.Fatalf("placeholder kind = %#v, want absent", got) + } + case <-time.After(time.Second): + t.Fatal("expected placeholder message to be delivered") + } +} + func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) { ch := newTestPicoChannel(t) diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 8a27b8c93..6e3a5ca89 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -1,6 +1,9 @@ package pico -import "time" +import ( + "strings" + "time" +) // Protocol message types. const ( @@ -19,10 +22,13 @@ const ( TypeError = "error" TypePong = "pong" - PayloadKeyContent = "content" - PayloadKeyThought = "thought" + PayloadKeyContent = "content" + PayloadKeyThought = "thought" + PayloadKeyKind = "kind" + PayloadKeyToolCalls = "tool_calls" - MessageKindThought = "thought" + MessageKindThought = "thought" + MessageKindToolCalls = "tool_calls" ) // PicoMessage is the wire format for all Pico Protocol messages. @@ -44,6 +50,13 @@ func newMessage(msgType string, payload map[string]any) PicoMessage { } func isThoughtPayload(payload map[string]any) bool { + kind, _ := payload[PayloadKeyKind].(string) + if strings.EqualFold(strings.TrimSpace(kind), MessageKindThought) { + return true + } + + // Keep pico_client inbound-compatible with legacy servers that still send + // the pre-kind boolean thought marker. thought, _ := payload[PayloadKeyThought].(bool) return thought } diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go index 95dc3e9d6..0614b6e32 100644 --- a/pkg/channels/telegram/parser_markdown_to_html.go +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -2,9 +2,13 @@ package telegram import ( "fmt" + "html" + "regexp" "strings" ) +var reRawURL = regexp.MustCompile(`https?://[^\s<]+`) + func markdownToTelegramHTML(text string) string { if text == "" { return "" @@ -19,6 +23,9 @@ func markdownToTelegramHTML(text string) string { links := extractLinks(text) text = links.text + rawURLs := extractRawURLs(text) + text = rawURLs.text + text = reHeading.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1") @@ -43,10 +50,19 @@ func markdownToTelegramHTML(text string) string { for i, lnk := range links.links { label := escapeHTML(lnk[0]) - url := lnk[1] + url := escapeHTMLAttr(lnk[1]) text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`%s`, url, label)) } + for i, rawURL := range rawURLs.urls { + escaped := escapeHTML(rawURL) + text = strings.ReplaceAll( + text, + fmt.Sprintf("\x00RU%d\x00", i), + fmt.Sprintf(`%s`, escapeHTMLAttr(rawURL), escaped), + ) + } + for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) @@ -92,6 +108,11 @@ type codeBlockMatch struct { codes []string } +type rawURLMatch struct { + text string + urls []string +} + func extractCodeBlocks(text string) codeBlockMatch { matches := reCodeBlock.FindAllStringSubmatch(text, -1) @@ -110,6 +131,24 @@ func extractCodeBlocks(text string) codeBlockMatch { return codeBlockMatch{text: text, codes: codes} } +func extractRawURLs(text string) rawURLMatch { + matches := reRawURL.FindAllString(text, -1) + + urls := make([]string, 0, len(matches)) + for _, match := range matches { + urls = append(urls, match) + } + + i := 0 + text = reRawURL.ReplaceAllStringFunc(text, func(string) string { + placeholder := fmt.Sprintf("\x00RU%d\x00", i) + i++ + return placeholder + }) + + return rawURLMatch{text: text, urls: urls} +} + type inlineCodeMatch struct { text string codes []string @@ -139,3 +178,7 @@ func escapeHTML(text string) string { text = strings.ReplaceAll(text, ">", ">") return text } + +func escapeHTMLAttr(text string) string { + return html.EscapeString(text) +} diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go index 7754ee076..a54a1c2c7 100644 --- a/pkg/channels/telegram/parser_markdown_to_html_test.go +++ b/pkg/channels/telegram/parser_markdown_to_html_test.go @@ -32,6 +32,11 @@ func Test_markdownToTelegramHTML(t *testing.T) { input: "[click here](https://example.com/path)", expected: `click here`, }, + { + name: "raw oauth url with underscores survives", + input: "Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256", + expected: `Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256`, + }, { name: "link with underscores in URL is not corrupted by italic regex", // Google Flights URLs use URL-safe base64 with underscores in the tfs param. @@ -45,6 +50,11 @@ func Test_markdownToTelegramHTML(t *testing.T) { input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)", expected: `first and second`, }, + { + name: "markdown link query params are escaped in href", + input: "[oauth](https://example.com/cb?response_type=code&client_id=test-client)", + expected: `oauth`, + }, { name: "link label with HTML special chars is escaped", input: "[a & b](https://example.com)", @@ -55,6 +65,11 @@ func Test_markdownToTelegramHTML(t *testing.T) { input: "a & b < c > d", expected: "a & b < c > d", }, + { + name: "code block with language", + input: "```json\n{\n \"path\": \"README.md\"\n}\n```", + expected: "
{\n  \"path\": \"README.md\"\n}\n
", + }, } for _, tc := range cases { diff --git a/pkg/config/config.go b/pkg/config/config.go index 161108638..dc9e88949 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -247,8 +247,9 @@ type SubTurnConfig struct { } type ToolFeedbackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` - MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` + Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` + MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` + SeparateMessages bool `json:"separate_messages" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_SEPARATE_MESSAGES"` } type AgentDefaults struct { @@ -286,7 +287,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } -// GetToolFeedbackMaxArgsLength returns the max visible text length for tool feedback messages. +// GetToolFeedbackMaxArgsLength returns the max visible text length for tool argument previews. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength @@ -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 { @@ -815,6 +823,7 @@ type ToolsConfig struct { ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + Serial ToolConfig `json:"serial" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SERIAL_"` SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"` Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` @@ -988,7 +997,9 @@ func LoadConfig(path string) (*Config, error) { Version int `json:"version"` } if e := json.Unmarshal(data, &versionInfo); e != nil { - return nil, fmt.Errorf("failed to detect config version: %w", e) + e = wrapJSONError(data, e, "config.json") + logger.ErrorCF("config", formatDiagnosticLogMessage("Malformed config file", e), map[string]any{"path": path}) + return nil, e } if len(data) <= 10 { logger.Warn(fmt.Sprintf("content is [%s]", string(data))) @@ -1003,10 +1014,23 @@ func LoadConfig(path string) (*Config, error) { "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } var m map[string]any m, err = loadConfigMap(path) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } @@ -1048,10 +1072,23 @@ func LoadConfig(path string) (*Config, error) { "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } var m map[string]any m, err = loadConfigMap(path) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } @@ -1093,9 +1130,22 @@ func LoadConfig(path string) (*Config, error) { "config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, ) + if err = validateLegacyConfigDiagnostics(data); err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) + return nil, err + } var m map[string]any m, err = loadConfigMap(path) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } migrateErr := migrateV2ToV3(m) @@ -1130,6 +1180,11 @@ func LoadConfig(path string) (*Config, error) { // Current version cfg, err = loadConfig(data) if err != nil { + logger.ErrorCF( + "config", + formatDiagnosticLogMessage("Failed to load config", err), + map[string]any{"path": path}, + ) return nil, err } // Load security configuration @@ -1494,6 +1549,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.Message.Enabled case "read_file": return t.ReadFile.Enabled + case "serial": + return t.Serial.Enabled case "spawn": return t.Spawn.Enabled case "spawn_status": diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 624cc7305..d455572eb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -787,6 +787,9 @@ func TestDefaultConfig_ToolFeedbackDisabled(t *testing.T) { if cfg.Agents.Defaults.ToolFeedback.Enabled { t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.Enabled should be false") } + if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { + t.Fatal("DefaultConfig().Agents.Defaults.ToolFeedback.SeparateMessages should be false") + } } func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { @@ -807,6 +810,9 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { if cfg.Agents.Defaults.ToolFeedback.Enabled { t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file") } + if cfg.Agents.Defaults.ToolFeedback.SeparateMessages { + t.Fatal("agents.defaults.tool_feedback.separate_messages should remain false when unset in config file") + } } func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { @@ -841,6 +847,72 @@ func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) { } } +func TestLoadConfig_SyntaxErrorReportsLineAndColumn(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"enabled\": true,,\n \"format\": \"markdown\"\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected syntax error, got nil") + } + if !strings.Contains(err.Error(), "syntax error at line 5, column 23") { + t.Fatalf("expected line/column diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "\"enabled\": true,,") { + t.Fatalf("expected source snippet in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "^") { + t.Fatalf("expected caret marker in diagnostic, got %q", err.Error()) + } +} + +func TestLoadConfig_TypeErrorReportsFieldPath(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"fetch_limit_bytes\": \"oops\"\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected type error, got nil") + } + if !strings.Contains(err.Error(), "type error at line 5, column 33") { + t.Fatalf("expected line/column diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "fetch_limit_bytes") { + t.Fatalf("expected field name in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "\"fetch_limit_bytes\": \"oops\"") { + t.Fatalf("expected source snippet in diagnostic, got %q", err.Error()) + } + if !strings.Contains(err.Error(), "^") { + t.Fatalf("expected caret marker in diagnostic, got %q", err.Error()) + } +} + +func TestLoadConfig_UnknownFieldsReportsExactPaths(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + raw := "{\n \"version\": 2,\n \"tools\": {\n \"weeb\": {\n \"enabled\": true\n },\n \"web\": {\n \"fatch_limit_bytes\": 123\n }\n }\n}\n" + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + _, err := LoadConfig(configPath) + if err == nil { + t.Fatal("expected unknown field error, got nil") + } + if !strings.Contains(err.Error(), "tools.weeb") || !strings.Contains(err.Error(), "tools.web.fatch_limit_bytes") { + t.Fatalf("expected exact unknown field paths, got %q", err.Error()) + } +} + func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Exec.AllowRemote { @@ -1349,25 +1421,12 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) { } // TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext -// api_key into memory but does NOT rewrite the config file. File writes are the sole +// api_keys entry into memory but does NOT rewrite the config file. File writes are the sole // responsibility of SaveConfig. func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.json") - const original = `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` - if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } - secPath := filepath.Join(dir, SecurityConfigFile) - const securityConfig = ` -model_list: - test:0: - api_keys: - - "sk-plaintext" -` - if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } + const original = `{"version":2,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_keys":["sk-plaintext"]}]}` if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { t.Fatalf("setup: %v", err) } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 35ef7cdd8..be8c32495 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -35,8 +35,9 @@ func DefaultConfig() *Config { SummarizeTokenPercent: 75, SteeringMode: "one-at-a-time", ToolFeedback: ToolFeedbackConfig{ - Enabled: false, - MaxArgsLength: 300, + Enabled: false, + MaxArgsLength: 300, + SeparateMessages: false, }, SplitOnMarker: false, }, @@ -434,6 +435,9 @@ func DefaultConfig() *Config { Mode: ReadFileModeBytes, MaxReadFileSize: 64 * 1024, // 64KB }, + Serial: ToolConfig{ + Enabled: false, // Hardware tool - requires host serial ports + }, Spawn: ToolConfig{ Enabled: true, }, diff --git a/pkg/config/diagnostics.go b/pkg/config/diagnostics.go new file mode 100644 index 000000000..bbc59c03b --- /dev/null +++ b/pkg/config/diagnostics.go @@ -0,0 +1,441 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strings" + "unicode/utf8" + + "golang.org/x/term" +) + +func decodeJSONWithDiagnostics(data []byte, target any, label string) error { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return wrapJSONError(data, err, label) + } + + unknownFields := collectUnknownJSONFields(raw, reflect.TypeOf(target), "") + if len(unknownFields) > 0 { + sort.Strings(unknownFields) + return fmt.Errorf( + "%s contains unknown field(s): %s", + label, + strings.Join(unknownFields, ", "), + ) + } + + if err := json.Unmarshal(data, target); err != nil { + return wrapJSONError(data, err, label) + } + return nil +} + +func DiagnosticSummary(err error) string { + if err == nil { + return "" + } + summary, _ := splitDiagnosticError(err.Error()) + return stripANSISequences(summary) +} + +func formatDiagnosticLogMessage(prefix string, err error) string { + if err == nil { + return prefix + } + + summary, preview := splitDiagnosticError(err.Error()) + summary = stripANSISequences(summary) + if preview == "" { + if summary == "" { + return prefix + } + return prefix + ": " + summary + } + if summary == "" { + return prefix + "\n" + preview + } + return prefix + ": " + summary + "\n" + preview +} + +func wrapJSONError(data []byte, err error, label string) error { + switch e := err.(type) { + case *json.SyntaxError: + line, column := lineAndColumnForOffset(data, e.Offset) + preview := diagnosticPreviewForOffset(data, e.Offset) + if preview != "" { + return fmt.Errorf( + "%s syntax error at line %d, column %d: %w\n%s", + label, + line, + column, + err, + preview, + ) + } + return fmt.Errorf("%s syntax error at line %d, column %d: %w", label, line, column, err) + case *json.UnmarshalTypeError: + line, column := lineAndColumnForOffset(data, e.Offset) + preview := diagnosticPreviewForOffset(data, e.Offset) + field := strings.TrimSpace(e.Field) + if field != "" { + if preview != "" { + return fmt.Errorf( + "%s type error at line %d, column %d for field %q: expected %s but got %s\n%s", + label, + line, + column, + field, + e.Type.String(), + e.Value, + preview, + ) + } + return fmt.Errorf( + "%s type error at line %d, column %d for field %q: expected %s but got %s", + label, + line, + column, + field, + e.Type.String(), + e.Value, + ) + } + if preview != "" { + return fmt.Errorf( + "%s type error at line %d, column %d: expected %s but got %s\n%s", + label, + line, + column, + e.Type.String(), + e.Value, + preview, + ) + } + return fmt.Errorf( + "%s type error at line %d, column %d: expected %s but got %s", + label, + line, + column, + e.Type.String(), + e.Value, + ) + default: + return fmt.Errorf("failed to parse %s: %w", label, err) + } +} + +func splitDiagnosticError(message string) (string, string) { + if idx := strings.IndexByte(message, '\n'); idx >= 0 { + return message[:idx], message[idx+1:] + } + return message, "" +} + +func stripANSISequences(s string) string { + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s)) + + for i := 0; i < len(s); i++ { + if s[i] != 0x1b { + b.WriteByte(s[i]) + continue + } + if i+1 >= len(s) || s[i+1] != '[' { + continue + } + i += 2 + for i < len(s) { + c := s[i] + if c >= '@' && c <= '~' { + break + } + i++ + } + } + + return b.String() +} + +func diagnosticPreviewForOffset(data []byte, offset int64) string { + if len(data) == 0 { + return "" + } + + start, end := lineBoundsForOffset(data, offset) + if start >= end { + return "" + } + + lineNumber, column := lineAndColumnForOffset(data, offset) + line := strings.TrimRight(string(data[start:end]), "\r\n") + if strings.TrimSpace(line) == "" { + return "" + } + + trimmedLine, trimOffset := trimDiagnosticLine(line, column) + if trimmedLine == "" { + return "" + } + + prefix := fmt.Sprintf("%4d | ", lineNumber) + caretColumn := column - trimOffset + if caretColumn < 1 { + caretColumn = 1 + } + + if diagnosticsUseColor() { + linePrefix := "\x1b[2m" + prefix + "\x1b[0m" + caretPrefix := "\x1b[2m" + strings.Repeat(" ", len(fmt.Sprintf("%4d", lineNumber))) + " | " + "\x1b[0m" + highlighted := highlightDiagnosticColumn(trimmedLine, caretColumn) + caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1)) + return fmt.Sprintf( + " %s%s\n %s%s\x1b[1;31m^\x1b[0m", + linePrefix, + highlighted, + caretPrefix, + caretPad, + ) + } + + caretPrefix := strings.Repeat(" ", len(prefix)) + caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1)) + return fmt.Sprintf( + " %s%s\n %s%s^", + prefix, + trimmedLine, + caretPrefix, + caretPad, + ) +} + +func lineAndColumnForOffset(data []byte, offset int64) (int, int) { + if offset <= 0 { + return 1, 1 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + + line := 1 + column := 1 + for i := int64(0); i < offset-1; i++ { + if data[i] == '\n' { + line++ + column = 1 + continue + } + column++ + } + return line, column +} + +func lineBoundsForOffset(data []byte, offset int64) (int, int) { + if len(data) == 0 { + return 0, 0 + } + + if offset <= 0 { + offset = 1 + } + if offset > int64(len(data)) { + offset = int64(len(data)) + } + + index := int(offset - 1) + if index < 0 { + index = 0 + } + if index >= len(data) { + index = len(data) - 1 + } + + start := index + for start > 0 && data[start-1] != '\n' { + start-- + } + + end := index + for end < len(data) && data[end] != '\n' { + end++ + } + + return start, end +} + +func trimDiagnosticLine(line string, column int) (string, int) { + runes := []rune(line) + if len(runes) == 0 { + return "", 0 + } + + if len(runes) <= 160 { + return line, 0 + } + + const contextBefore = 60 + const maxWidth = 160 + + start := column - 1 - contextBefore + if start < 0 { + start = 0 + } + if start > len(runes)-maxWidth { + start = len(runes) - maxWidth + } + if start < 0 { + start = 0 + } + + end := start + maxWidth + if end > len(runes) { + end = len(runes) + } + + trimmed := string(runes[start:end]) + trimOffset := start + + if start > 0 { + trimmed = "..." + trimmed + trimOffset -= 3 + } + if end < len(runes) { + trimmed += "..." + } + + return trimmed, trimOffset +} + +func diagnosticsUseColor() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +func highlightDiagnosticColumn(line string, column int) string { + runes := []rune(line) + if column < 1 || column > len(runes) { + return line + } + + index := column - 1 + return string(runes[:index]) + "\x1b[31m" + string(runes[index]) + "\x1b[0m" + string(runes[index+1:]) +} + +func maxRuneCount(s string, count int) int { + if count <= 0 { + return 0 + } + runes := []rune(s) + if count > len(runes) { + count = len(runes) + } + return utf8.RuneCountInString(string(runes[:count])) +} + +func collectUnknownJSONFields(raw any, targetType reflect.Type, path string) []string { + targetType = derefType(targetType) + if targetType == nil { + return nil + } + + switch targetType.Kind() { + case reflect.Struct: + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + fieldMap := jsonFieldTypeMap(targetType) + var issues []string + for key, value := range obj { + fieldType, exists := fieldMap[key] + fieldPath := appendJSONPath(path, key) + if !exists { + issues = append(issues, fieldPath) + continue + } + issues = append(issues, collectUnknownJSONFields(value, fieldType, fieldPath)...) + } + return issues + case reflect.Slice, reflect.Array: + items, ok := raw.([]any) + if !ok { + return nil + } + var issues []string + elemType := targetType.Elem() + for i, item := range items { + itemPath := fmt.Sprintf("%s[%d]", path, i) + issues = append(issues, collectUnknownJSONFields(item, elemType, itemPath)...) + } + return issues + case reflect.Map: + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + var issues []string + elemType := targetType.Elem() + for key, value := range obj { + fieldPath := appendJSONPath(path, key) + issues = append(issues, collectUnknownJSONFields(value, elemType, fieldPath)...) + } + return issues + default: + return nil + } +} + +func jsonFieldTypeMap(t reflect.Type) map[string]reflect.Type { + result := make(map[string]reflect.Type) + populateJSONFieldTypeMap(result, derefType(t)) + return result +} + +func populateJSONFieldTypeMap(result map[string]reflect.Type, t reflect.Type) { + if t == nil || t.Kind() != reflect.Struct { + return + } + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + tag := field.Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "-" { + continue + } + + if field.Anonymous && name == "" { + populateJSONFieldTypeMap(result, derefType(field.Type)) + continue + } + + if name == "" { + name = field.Name + } + result[name] = field.Type + } +} + +func derefType(t reflect.Type) reflect.Type { + for t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} + +func appendJSONPath(path, segment string) string { + if path == "" { + return segment + } + return path + "." + segment +} diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 4fe2148b2..96914819e 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -27,6 +27,59 @@ func buildModelWithProtocol(protocol, model string) string { return protocol + "/" + model } +type legacyDiagnosticConfig struct { + Version int `json:"version"` + Isolation IsolationConfig `json:"isolation,omitempty"` + Agents legacyDiagnosticAgents `json:"agents,omitempty"` + Session SessionConfig `json:"session,omitempty"` + Channels map[string]any `json:"channels,omitempty"` + ChannelList ChannelsConfig `json:"channel_list,omitempty"` + ModelList []map[string]any `json:"model_list,omitempty"` + Gateway GatewayConfig `json:"gateway,omitempty"` + Hooks HooksConfig `json:"hooks,omitempty"` + Tools ToolsConfig `json:"tools,omitempty"` + Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"` + Devices DevicesConfig `json:"devices,omitempty"` + Voice VoiceConfig `json:"voice,omitempty"` + Bindings json.RawMessage `json:"bindings,omitempty"` + Providers json.RawMessage `json:"providers,omitempty"` +} + +type legacyDiagnosticAgents struct { + Defaults legacyDiagnosticAgentDefaults `json:"defaults,omitempty"` + List []AgentConfig `json:"list,omitempty"` + Dispatch *DispatchConfig `json:"dispatch,omitempty"` +} + +type legacyDiagnosticAgentDefaults struct { + AgentDefaults + LegacyModel string `json:"model,omitempty"` +} + +func validateLegacyConfigDiagnostics(data []byte) error { + var cfg legacyDiagnosticConfig + return decodeJSONWithDiagnostics(data, &cfg, "config.json") +} + +func migrateLegacyAgentDefaultsModel(m map[string]any) { + agents, ok := m["agents"].(map[string]any) + if !ok { + return + } + defaults, ok := agents["defaults"].(map[string]any) + if !ok { + return + } + model, hasModel := defaults["model"] + if !hasModel { + return + } + if _, hasModelName := defaults["model_name"]; !hasModelName { + defaults["model_name"] = model + } + delete(defaults, "model") +} + // loadConfigV1 loads a version 1 config (current schema) func loadConfig(data []byte) (*Config, error) { cfg := DefaultConfig() @@ -38,14 +91,14 @@ func loadConfig(data []byte) (*Config, error) { // index position. We only reset cfg.ModelList when the user actually provides // entries; when count is 0 we keep DefaultConfig's built-in list as fallback. var tmp Config - if err := json.Unmarshal(data, &tmp); err != nil { + if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil { return nil, err } if len(tmp.ModelList) > 0 { cfg.ModelList = nil } - if err := json.Unmarshal(data, cfg); err != nil { + if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil { return nil, err } return cfg, nil @@ -96,17 +149,7 @@ func migrateV0ToV1(m map[string]any) error { return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"]) } - // Migrate agents.defaults.model → agents.defaults.model_name - if agents, ok := m["agents"].(map[string]any); ok { - if defaults, ok := agents["defaults"].(map[string]any); ok { - if model, hasModel := defaults["model"]; hasModel { - if _, hasModelName := defaults["model_name"]; !hasModelName { - defaults["model_name"] = model - } - delete(defaults, "model") - } - } - } + migrateLegacyAgentDefaultsModel(m) // Migrate legacy providers to model_list if no model_list exists if _, hasModelList := m["model_list"]; !hasModelList { @@ -275,6 +318,9 @@ func migrateV2ToV3(m map[string]any) error { return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"]) } + migrateLegacyAgentDefaultsModel(m) + delete(m, "bindings") + // Rename channels → channel_list if channels, ok := m["channels"]; ok { delete(m, "channels") @@ -334,7 +380,7 @@ func loadConfigMap(path string) (map[string]any, error) { return nil, fmt.Errorf("failed to read config: %w", err) } if err = json.Unmarshal(data, &m1); err != nil { - return nil, fmt.Errorf("failed to parse config: %w", err) + return nil, wrapJSONError(data, err, "config.json") } secPath := securityPath(path) data, err = os.ReadFile(secPath) diff --git a/pkg/config/security.go b/pkg/config/security.go index c5d3bf507..9f0d1339c 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -75,7 +75,7 @@ func loadSecurityConfig(cfg *Config, securityPath string) error { // Unmarshal non-channel fields from security.yml // This will resolve encrypted values for model_list, tools, etc. if err := yaml.Unmarshal(data, cfg); err != nil { - return fmt.Errorf("failed to parse security config: %w", err) + return fmt.Errorf("failed to parse security config %s: %w", securityPath, err) } if err := applyLegacySkillsSecurityConfig(cfg, data); err != nil { return fmt.Errorf("failed to parse legacy skills security config: %w", err) diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 5fe7b6b97..8fc2f167c 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -43,11 +43,10 @@ func TestSecurityConfigIntegration(t *testing.T) { t.Run("Full workflow with security references", func(t *testing.T) { tmpDir := t.TempDir() - // Create config.json with direct security values (not ref: references) - // These values should take precedence over .security.yml + // Create config.json with direct security values using the current schema. configPath := filepath.Join(tmpDir, "config.json") configContent := `{ - "version": 1, + "version": 2, "model_list": [ { "model_name": "test-model", diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go index 9b39c85cf..1b3be8bd3 100644 --- a/pkg/isolation/platform_windows.go +++ b/pkg/isolation/platform_windows.go @@ -102,7 +102,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, return fmt.Errorf("open process for job assignment: %w", err) } - if err := windows.AssignProcessToJobObject(job, proc); err != nil { + if err = windows.AssignProcessToJobObject(job, proc); err != nil { _ = windows.CloseHandle(proc) _ = windows.CloseHandle(job) if resources.token != 0 { diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index f589f82a9..92ea426a6 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -25,6 +25,24 @@ type headerTransport struct { headers map[string]string } +func expandHomeCommandPath(command string) string { + if command == "" || command[0] != '~' { + return command + } + + home, err := os.UserHomeDir() + if err != nil { + return command + } + if command == "~" { + return home + } + if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") { + return filepath.Join(home, command[2:]) + } + return command +} + func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original req = req.Clone(req.Context()) @@ -99,10 +117,12 @@ func loadEnvFile(path string) (map[string]string, error) { // ServerConnection represents a connection to an MCP server type ServerConnection struct { - Name string - Client *mcp.Client - Session *mcp.ClientSession - Tools []*mcp.Tool + Name string + Config config.MCPServerConfig + Client *mcp.Client + Session *mcp.ClientSession + Tools []*mcp.Tool + reconnectMu sync.Mutex } // Manager manages multiple MCP server connections @@ -113,6 +133,8 @@ type Manager struct { wg sync.WaitGroup // tracks in-flight CallTool calls } +var connectServerFunc = connectServer + // NewManager creates a new MCP manager func NewManager() *Manager { return &Manager{ @@ -242,6 +264,28 @@ func (m *Manager) ConnectServer( name string, cfg config.MCPServerConfig, ) error { + conn, err := connectServerFunc(ctx, name, cfg) + if err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.closed.Load() { + _ = conn.Session.Close() + return fmt.Errorf("manager is closed") + } + + m.servers[name] = conn + return nil +} + +func connectServer( + ctx context.Context, + name string, + cfg config.MCPServerConfig, +) (*ServerConnection, error) { logger.InfoCF("mcp", "Connecting to MCP server", map[string]any{ "server": name, @@ -267,14 +311,14 @@ func (m *Manager) ConnectServer( } else if cfg.Command != "" { transportType = "stdio" } else { - return fmt.Errorf("either URL or command must be provided") + return nil, fmt.Errorf("either URL or command must be provided") } } switch transportType { case "sse", "http": if cfg.URL == "" { - return fmt.Errorf("URL is required for SSE/HTTP transport") + return nil, fmt.Errorf("URL is required for SSE/HTTP transport") } // Configure DisableStandaloneSSE based on transport type. @@ -316,7 +360,7 @@ func (m *Manager) ConnectServer( transport = sseTransport case "stdio": if cfg.Command == "" { - return fmt.Errorf("command is required for stdio transport") + return nil, fmt.Errorf("command is required for stdio transport") } logger.DebugCF("mcp", "Using stdio transport", map[string]any{ @@ -324,7 +368,7 @@ func (m *Manager) ConnectServer( "command": cfg.Command, }) // Create command with context - cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) + cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...) // Build environment variables with proper override semantics // Use a map to ensure config variables override file variables @@ -341,7 +385,7 @@ func (m *Manager) ConnectServer( if cfg.EnvFile != "" { envVars, err := loadEnvFile(cfg.EnvFile) if err != nil { - return fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) + return nil, fmt.Errorf("failed to load env file %s: %w", cfg.EnvFile, err) } for k, v := range envVars { envMap[k] = v @@ -367,7 +411,7 @@ func (m *Manager) ConnectServer( cmd.Env = env transport = &isolatedCommandTransport{Command: cmd} default: - return fmt.Errorf( + return nil, fmt.Errorf( "unsupported transport type: %s (supported: stdio, sse, http)", transportType, ) @@ -376,7 +420,7 @@ func (m *Manager) ConnectServer( // Connect to server session, err := client.Connect(ctx, transport, nil) if err != nil { - return fmt.Errorf("failed to connect: %w", err) + return nil, fmt.Errorf("failed to connect: %w", err) } // Get server info @@ -390,38 +434,19 @@ func (m *Manager) ConnectServer( }) // List available tools if supported - var tools []*mcp.Tool - if initResult.Capabilities.Tools != nil { - for tool, err := range session.Tools(ctx, nil) { - if err != nil { - logger.WarnCF("mcp", "Error listing tool", - map[string]any{ - "server": name, - "error": err.Error(), - }) - continue - } - tools = append(tools, tool) - } - - logger.InfoCF("mcp", "Listed tools from MCP server", - map[string]any{ - "server": name, - "toolCount": len(tools), - }) + tools, err := listServerTools(ctx, name, session, initResult) + if err != nil { + _ = session.Close() + return nil, err } - // Store connection - m.mu.Lock() - m.servers[name] = &ServerConnection{ + return &ServerConnection{ Name: name, + Config: cfg, Client: client, Session: session, Tools: tools, - } - m.mu.Unlock() - - return nil + }, nil } // GetServers returns all connected servers @@ -480,12 +505,131 @@ func (m *Manager) CallTool( result, err := conn.Session.CallTool(ctx, params) if err != nil { + if shouldReconnectCallError(err) { + logger.WarnCF("mcp", "MCP server session was lost during tool call, reconnecting", + map[string]any{ + "server": serverName, + "tool": toolName, + "error": err.Error(), + }) + + reconnectedConn, reconnectErr := m.reconnectServer(ctx, serverName, conn) + if reconnectErr != nil { + return nil, fmt.Errorf("failed to recover lost MCP session: %w", reconnectErr) + } + + result, err = reconnectedConn.Session.CallTool(ctx, params) + if err == nil { + return result, nil + } + } + return nil, fmt.Errorf("failed to call tool: %w", err) } return result, nil } +func listServerTools( + ctx context.Context, + name string, + session *mcp.ClientSession, + initResult *mcp.InitializeResult, +) ([]*mcp.Tool, error) { + var tools []*mcp.Tool + if initResult.Capabilities.Tools == nil { + return tools, nil + } + + for tool, err := range session.Tools(ctx, nil) { + if err != nil { + logger.WarnCF("mcp", "Error listing tool", + map[string]any{ + "server": name, + "error": err.Error(), + }) + continue + } + tools = append(tools, tool) + } + + logger.InfoCF("mcp", "Listed tools from MCP server", + map[string]any{ + "server": name, + "toolCount": len(tools), + }) + + return tools, nil +} + +func shouldReconnectCallError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, mcp.ErrSessionMissing) { + return true + } + return strings.Contains(strings.ToLower(err.Error()), mcp.ErrSessionMissing.Error()) +} + +func (m *Manager) reconnectServer( + ctx context.Context, + serverName string, + staleConn *ServerConnection, +) (*ServerConnection, error) { + if staleConn == nil { + return nil, fmt.Errorf("server %s not found", serverName) + } + + staleConn.reconnectMu.Lock() + defer staleConn.reconnectMu.Unlock() + + if m.closed.Load() { + return nil, fmt.Errorf("manager is closed") + } + + m.mu.RLock() + currentConn, ok := m.servers[serverName] + m.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("server %s not found", serverName) + } + if currentConn != staleConn { + return currentConn, nil + } + + freshConn, err := connectServerFunc(ctx, serverName, staleConn.Config) + if err != nil { + return nil, err + } + + m.mu.Lock() + if m.closed.Load() { + m.mu.Unlock() + _ = freshConn.Session.Close() + return nil, fmt.Errorf("manager is closed") + } + + currentConn, ok = m.servers[serverName] + if !ok { + m.mu.Unlock() + _ = freshConn.Session.Close() + return nil, fmt.Errorf("server %s not found", serverName) + } + + if currentConn == staleConn { + m.servers[serverName] = freshConn + staleToClose := staleConn + m.mu.Unlock() + _ = staleToClose.Session.Close() + return freshConn, nil + } + + m.mu.Unlock() + _ = freshConn.Session.Close() + return currentConn, nil +} + // Close closes all server connections func (m *Manager) Close() error { // Use Swap to atomically set closed=true and get the previous value diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index f353942ab..682d4c346 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -2,11 +2,16 @@ package mcp import ( "context" + "encoding/json" + "fmt" + "io" "os" "path/filepath" "strings" + "sync" "testing" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/config" @@ -136,6 +141,22 @@ func TestLoadEnvFileNotFound(t *testing.T) { } } +func TestExpandHomeCommandPath(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + want := filepath.Join(homeDir, "bin", "my-mcp") + got := expandHomeCommandPath("~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")) + if got != want { + t.Fatalf("expandHomeCommandPath() = %q, want %q", got, want) + } + + if got := expandHomeCommandPath("npx"); got != "npx" { + t.Fatalf("expandHomeCommandPath() should leave bare commands unchanged, got %q", got) + } +} + func TestEnvFilePriority(t *testing.T) { // Create a temporary .env file tmpDir := t.TempDir() @@ -296,6 +317,81 @@ func TestCallTool_ErrorsForClosedOrMissingServer(t *testing.T) { }) } +func TestCallTool_ReconnectsWhenHTTPServerLosesSession(t *testing.T) { + originalConnectServerFunc := connectServerFunc + t.Cleanup(func() { + connectServerFunc = originalConnectServerFunc + }) + + staleConn, staleTransport, err := newScriptedServerConnection( + "session-1", + nil, + fmt.Errorf(`sending "tools/call": failed to connect (session ID: session-1): %w`, sdkmcp.ErrSessionMissing), + ) + if err != nil { + t.Fatalf("newScriptedServerConnection(stale) error = %v", err) + } + freshConn, freshTransport, err := newScriptedServerConnection( + "session-2", + &sdkmcp.CallToolResult{ + Content: []sdkmcp.Content{ + &sdkmcp.TextContent{Text: "reconnected"}, + }, + }, + nil, + ) + if err != nil { + t.Fatalf("newScriptedServerConnection(fresh) error = %v", err) + } + + connectCalls := 0 + connectServerFunc = func(ctx context.Context, name string, cfg config.MCPServerConfig) (*ServerConnection, error) { + connectCalls++ + if connectCalls == 1 { + return freshConn, nil + } + return nil, fmt.Errorf("unexpected reconnect attempt %d", connectCalls) + } + + mgr := NewManager() + mgr.servers["flaky"] = staleConn + + result, err := mgr.CallTool(context.Background(), "flaky", "echo", map[string]any{ + "query": "hello", + }) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if result == nil || len(result.Content) != 1 { + t.Fatalf("CallTool() returned unexpected content: %#v", result) + } + + text, ok := result.Content[0].(*sdkmcp.TextContent) + if !ok { + t.Fatalf("CallTool() content type = %T, want *sdkmcp.TextContent", result.Content[0]) + } + if text.Text != "reconnected" { + t.Fatalf("CallTool() text = %q, want %q", text.Text, "reconnected") + } + + conn, ok := mgr.GetServer("flaky") + if !ok { + t.Fatal("expected flaky server to remain connected after reconnect") + } + if conn.Session.ID() != "session-2" { + t.Fatalf("Session.ID() = %q, want %q", conn.Session.ID(), "session-2") + } + if connectCalls != 1 { + t.Fatalf("connectCalls = %d, want 1", connectCalls) + } + if staleTransport.toolCallCalls != 1 { + t.Fatalf("stale toolCallCalls = %d, want 1", staleTransport.toolCallCalls) + } + if freshTransport.toolCallCalls != 1 { + t.Fatalf("fresh toolCallCalls = %d, want 1", freshTransport.toolCallCalls) + } +} + func TestClose_IdempotentOnEmptyManager(t *testing.T) { mgr := NewManager() @@ -306,3 +402,138 @@ func TestClose_IdempotentOnEmptyManager(t *testing.T) { t.Fatalf("second close should be idempotent, got: %v", err) } } + +func newScriptedServerConnection( + sessionID string, + toolCallResult *sdkmcp.CallToolResult, + toolCallErr error, +) (*ServerConnection, *scriptedTransport, error) { + transport := &scriptedTransport{ + sessionID: sessionID, + toolCallResult: toolCallResult, + toolCallErr: toolCallErr, + } + + client := sdkmcp.NewClient(&sdkmcp.Implementation{ + Name: "picoclaw-test", + Version: "1.0.0", + }, nil) + session, err := client.Connect(context.Background(), transport, nil) + if err != nil { + return nil, nil, err + } + + return &ServerConnection{ + Name: "flaky", + Config: config.MCPServerConfig{Enabled: true, Type: "http", URL: "https://example.invalid/mcp"}, + Client: client, + Session: session, + Tools: []*sdkmcp.Tool{ + { + Name: "echo", + Description: "Echo test tool", + InputSchema: map[string]any{"type": "object"}, + }, + }, + }, transport, nil +} + +type scriptedTransport struct { + sessionID string + toolCallResult *sdkmcp.CallToolResult + toolCallErr error + + mu sync.Mutex + toolCallCalls int + closed bool + incoming chan jsonrpc.Message +} + +func (t *scriptedTransport) Connect(context.Context) (sdkmcp.Connection, error) { + if t.incoming == nil { + t.incoming = make(chan jsonrpc.Message, 4) + } + return t, nil +} + +func (t *scriptedTransport) Read(ctx context.Context) (jsonrpc.Message, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case msg, ok := <-t.incoming: + if !ok { + return nil, io.EOF + } + return msg, nil + } +} + +func (t *scriptedTransport) Write(ctx context.Context, msg jsonrpc.Message) error { + req, ok := msg.(*jsonrpc.Request) + if !ok { + return nil + } + + switch req.Method { + case "initialize": + payload, err := json.Marshal(&sdkmcp.InitializeResult{ + ProtocolVersion: "2025-11-25", + ServerInfo: &sdkmcp.Implementation{ + Name: "scripted-test-server", + Version: "1.0.0", + }, + Capabilities: &sdkmcp.ServerCapabilities{ + Tools: &sdkmcp.ToolCapabilities{}, + }, + }) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}: + return nil + } + + case "notifications/initialized": + return nil + + case "tools/call": + t.mu.Lock() + t.toolCallCalls++ + t.mu.Unlock() + + if t.toolCallErr != nil { + return t.toolCallErr + } + + payload, err := json.Marshal(t.toolCallResult) + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case t.incoming <- &jsonrpc.Response{ID: req.ID, Result: payload}: + return nil + } + } + + return fmt.Errorf("unexpected method %q", req.Method) +} + +func (t *scriptedTransport) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.closed { + return nil + } + t.closed = true + close(t.incoming) + return nil +} + +func (t *scriptedTransport) SessionID() string { + return t.sessionID +} diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index 8d3320f3f..492205114 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -10,12 +10,14 @@ import ( "log" "os" "path/filepath" + "sort" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" ) const ( @@ -405,12 +407,9 @@ func (s *JSONLStore) promoteAliasHistoryLocked( } func (s *JSONLStore) sessionHasVisibleContentLocked(sessionKey string, meta SessionMeta) (bool, error) { - if meta.Count-meta.Skip > 0 || strings.TrimSpace(meta.Summary) != "" { + if strings.TrimSpace(meta.Summary) != "" { return true, nil } - if meta.Count != 0 || meta.Skip != 0 { - return false, nil - } history, err := readMessages(s.jsonlPath(sessionKey), meta.Skip) if err != nil { return false, err @@ -482,6 +481,9 @@ func readMessages(path string, skip int) ([]providers.Message, error) { lineNum, filepath.Base(path), err) continue } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } msgs = append(msgs, msg) } if scanner.Err() != nil { @@ -494,28 +496,44 @@ func readMessages(path string, skip int) ([]providers.Message, error) { return msgs, nil } -// countLines counts the total number of non-empty lines in a .jsonl file. -// Used by TruncateHistory to reconcile a stale meta.Count without -// the overhead of unmarshaling every message. -func countLines(path string) (int, error) { +// scanRetainedMessageLines returns the total number of non-empty raw JSONL +// lines plus the raw line numbers that survive readMessages filtering. +// TruncateHistory uses this to compute keepLast against retained messages +// while preserving the raw-line skip offset stored in metadata. +func scanRetainedMessageLines(path string) (int, []int, error) { f, err := os.Open(path) if os.IsNotExist(err) { - return 0, nil + return 0, []int{}, nil } if err != nil { - return 0, fmt.Errorf("memory: open jsonl: %w", err) + return 0, nil, fmt.Errorf("memory: open jsonl: %w", err) } defer f.Close() - n := 0 + rawCount := 0 + retained := make([]int, 0) scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) for scanner.Scan() { - if len(scanner.Bytes()) > 0 { - n++ + line := scanner.Bytes() + if len(line) == 0 { + continue } + rawCount++ + + var msg providers.Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + if messageutil.IsTransientAssistantThoughtMessage(msg) { + continue + } + retained = append(retained, rawCount) } - return n, scanner.Err() + if err := scanner.Err(); err != nil { + return 0, nil, err + } + return rawCount, retained, nil } func (s *JSONLStore) AddMessage( @@ -535,6 +553,10 @@ func (s *JSONLStore) AddFullMessage( // addMsg is the shared implementation for AddMessage and AddFullMessage. func (s *JSONLStore) addMsg(sessionKey string, msg providers.Message) error { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return nil + } + l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() @@ -655,24 +677,26 @@ func (s *JSONLStore) TruncateHistory( return err } - // Always reconcile meta.Count with the actual line count on disk. - // A crash between the JSONL append and the meta update in addMsg - // leaves meta.Count stale (e.g. file has 101 lines but meta says - // 100). Counting lines is cheap — no unmarshal, just a scan — and - // TruncateHistory is not a hot path, so always re-count. - n, countErr := countLines(s.jsonlPath(sessionKey)) - if countErr != nil { - return countErr + rawCount, retainedRawLines, scanErr := scanRetainedMessageLines(s.jsonlPath(sessionKey)) + if scanErr != nil { + return scanErr } - meta.Count = n - - if keepLast <= 0 { + meta.Count = rawCount + if meta.Skip > meta.Count { meta.Skip = meta.Count - } else { - effective := meta.Count - meta.Skip - if keepLast < effective { - meta.Skip = meta.Count - keepLast - } + } + + activeStart := sort.Search(len(retainedRawLines), func(i int) bool { + return retainedRawLines[i] > meta.Skip + }) + activeRetainedCount := len(retainedRawLines) - activeStart + + switch { + case keepLast <= 0 || activeRetainedCount == 0: + meta.Skip = meta.Count + case keepLast < activeRetainedCount: + activeRawLines := retainedRawLines[activeStart:] + meta.Skip = activeRawLines[activeRetainedCount-keepLast-1] } meta.UpdatedAt = time.Now() @@ -684,6 +708,8 @@ func (s *JSONLStore) SetHistory( sessionKey string, history []providers.Message, ) error { + history = messageutil.FilterInvalidHistoryMessages(history) + l := s.sessionLock(sessionKey) l.Lock() defer l.Unlock() @@ -762,6 +788,8 @@ func (s *JSONLStore) Compact( func (s *JSONLStore) rewriteJSONL( sessionKey string, msgs []providers.Message, ) error { + msgs = messageutil.FilterInvalidHistoryMessages(msgs) + var buf bytes.Buffer for i, msg := range msgs { line, err := json.Marshal(msg) diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index b64c1b25f..3a7b98130 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "reflect" + "strings" "sync" "testing" + "time" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -155,6 +157,27 @@ func TestAddFullMessage_ToolCallID(t *testing.T) { } } +func TestAddFullMessage_DropsTransientAssistantThought(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + err := store.AddFullMessage(ctx, "transient-thought", providers.Message{ + Role: "assistant", + ReasoningContent: "internal chain of thought", + }) + if err != nil { + t.Fatalf("AddFullMessage: %v", err) + } + + history, err := store.GetHistory(ctx, "transient-thought") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 0 { + t.Fatalf("expected transient thought to be discarded, got %d messages", len(history)) + } +} + func TestGetHistory_EmptySession(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -243,6 +266,46 @@ func TestSetSummary_GetSummary(t *testing.T) { } } +func TestSetHistory_DropsTransientAssistantThought(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + newHistory := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", ReasoningContent: "internal chain of thought"}, + {Role: "assistant", Content: "visible answer", ReasoningContent: "visible thought"}, + } + + err := store.SetHistory(ctx, "replace", newHistory) + if err != nil { + t.Fatalf("SetHistory: %v", err) + } + + history, err := store.GetHistory(ctx, "replace") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected transient thought to be removed, got %d messages", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Fatalf("history[0] = %+v, want user/hello", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "visible answer" || + history[1].ReasoningContent != "visible thought" { + t.Fatalf("history[1] = %+v, want assistant visible answer with reasoning", history[1]) + } + + data, err := os.ReadFile(store.jsonlPath("replace")) + if err != nil { + t.Fatalf("ReadFile(jsonl): %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("jsonl line count = %d, want 2", len(lines)) + } +} + func TestSessionMetaScopeAndAliasesPersist(t *testing.T) { store := newTestStore(t) ctx := context.Background() @@ -733,6 +796,56 @@ func TestTruncateHistory_StaleMetaCount(t *testing.T) { } } +func TestTruncateHistory_IgnoresTransientThoughtForKeepLast(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + sessionKey := "transient-keep-last" + now := time.Now() + + rawJSONL := strings.Join([]string{ + `{"role":"user","content":"a"}`, + `{"role":"assistant","content":"b"}`, + `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`, + `{"role":"user","content":"c"}`, + `{"role":"assistant","content":"d"}`, + }, "\n") + "\n" + if err := os.WriteFile(store.jsonlPath(sessionKey), []byte(rawJSONL), 0o644); err != nil { + t.Fatalf("WriteFile(jsonl): %v", err) + } + if err := store.writeMeta(sessionKey, SessionMeta{ + Key: sessionKey, + Count: 5, + Skip: 0, + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("writeMeta: %v", err) + } + + if err := store.TruncateHistory(ctx, sessionKey, 2); err != nil { + t.Fatalf("TruncateHistory: %v", err) + } + + history, err := store.GetHistory(ctx, sessionKey) + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 2 { + t.Fatalf("expected 2 retained messages, got %d", len(history)) + } + if history[0].Content != "c" || history[1].Content != "d" { + t.Fatalf("kept history = %+v, want c,d", history) + } + + meta, err := store.readMeta(sessionKey) + if err != nil { + t.Fatalf("readMeta: %v", err) + } + if meta.Skip != 2 { + t.Fatalf("meta.Skip = %d, want 2 raw lines skipped", meta.Skip) + } +} + func TestCrashRecovery_PartialLine(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go index f7c1f42b2..00601195f 100644 --- a/pkg/pid/pidfile.go +++ b/pkg/pid/pidfile.go @@ -58,7 +58,12 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) { if data, err := readPidFileUnlocked(pidPath); err == nil { if os.Getpid() != data.PID { logger.Infof("found pid file (PID: %d, version: %s)", data.PID, data.Version) - if isProcessRunning(data.PID) { + // PID 1 is typically init/systemd on the host or the entrypoint + // inside a container. When a container stops and leaves behind a + // PID file on a shared volume, the host's PID 1 (init) would + // pass the isProcessRunning check, blocking new gateway starts. + // Treat recorded PID 1 as always stale. + if data.PID != 1 && isProcessRunning(data.PID) { return nil, fmt.Errorf("gateway is already running (PID: %d, version: %s)", data.PID, data.Version) } logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath) @@ -124,6 +129,14 @@ func ReadPidFileWithCheck(homePath string) *PidFileData { return nil } + // Treat PID 1 as stale when we are not PID 1 ourselves (container + // leftover on a shared volume — host PID 1 is init, not gateway). + if data.PID == 1 && os.Getpid() != 1 { + logger.Debugf("stale container PID 1, remove pid file: %s", pidPath) + os.Remove(pidPath) + return nil + } + if !isProcessRunning(data.PID) { logger.Debugf("process not running, remove pid file: %s", pidPath) os.Remove(pidPath) diff --git a/pkg/pid/pidfile_test.go b/pkg/pid/pidfile_test.go index 2da44bbbc..2d3c11f63 100644 --- a/pkg/pid/pidfile_test.go +++ b/pkg/pid/pidfile_test.go @@ -278,6 +278,46 @@ func TestRemovePidFileIfPIDMismatch(t *testing.T) { } } +// TestWritePidFileContainerPID1 verifies that a leftover PID file with PID 1 +// (typical container entrypoint) is treated as stale and overwritten. +func TestWritePidFileContainerPID1(t *testing.T) { + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data, err := WritePidFile(dir, "127.0.0.1", 18790) + if err != nil { + t.Fatalf("WritePidFile should treat PID 1 as stale, got error: %v", err) + } + if data.PID != os.Getpid() { + t.Errorf("PID = %d, want %d", data.PID, os.Getpid()) + } +} + +// TestReadPidFileWithCheckContainerPID1 verifies that a leftover PID file +// with PID 1 is treated as stale and cleaned up. +func TestReadPidFileWithCheckContainerPID1(t *testing.T) { + if os.Getpid() == 1 { + t.Skip("test not meaningful when running as PID 1") + } + dir := tmpDir(t) + + stale := PidFileData{PID: 1, Token: "deadbeef12345678deadbeef12345678"} + raw, _ := json.MarshalIndent(stale, "", " ") + os.WriteFile(filepath.Join(dir, pidFileName), raw, 0o600) + + data := ReadPidFileWithCheck(dir) + if data != nil { + t.Error("expected nil for PID 1 leftover") + } + + if _, err := os.Stat(filepath.Join(dir, pidFileName)); !os.IsNotExist(err) { + t.Error("PID 1 leftover file should be removed") + } +} + // TestReadPidFileUnlockedInvalidJSON returns error for malformed content. func TestReadPidFileUnlockedInvalidJSON(t *testing.T) { dir := tmpDir(t) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 86d009811..ce83c6c54 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -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) diff --git a/pkg/providers/httpapi/http_provider.go b/pkg/providers/httpapi/http_provider.go index a84962622..90f389cc8 100644 --- a/pkg/providers/httpapi/http_provider.go +++ b/pkg/providers/httpapi/http_provider.go @@ -77,3 +77,10 @@ func (p *HTTPProvider) GetDefaultModel() string { func (p *HTTPProvider) SupportsNativeSearch() bool { return p.delegate.SupportsNativeSearch() } + +func (p *HTTPProvider) SetProviderName(providerName string) { + if p == nil || p.delegate == nil { + return + } + p.delegate.SetProviderName(providerName) +} diff --git a/pkg/providers/messageutil/messageutil.go b/pkg/providers/messageutil/messageutil.go new file mode 100644 index 000000000..c4382d894 --- /dev/null +++ b/pkg/providers/messageutil/messageutil.go @@ -0,0 +1,38 @@ +package messageutil + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// IsTransientAssistantThoughtMessage reports whether msg is an invalid +// reasoning-only assistant history record. These "hanging" thought messages +// are not a canonical persisted format and should be discarded instead of +// replayed or reconstructed. +func IsTransientAssistantThoughtMessage(msg protocoltypes.Message) bool { + return msg.Role == "assistant" && + strings.TrimSpace(msg.Content) == "" && + strings.TrimSpace(msg.ReasoningContent) != "" && + len(msg.ToolCalls) == 0 && + len(msg.Media) == 0 && + len(msg.Attachments) == 0 && + strings.TrimSpace(msg.ToolCallID) == "" +} + +// FilterInvalidHistoryMessages removes invalid persisted history records such +// as transient assistant thought-only messages. +func FilterInvalidHistoryMessages(history []protocoltypes.Message) []protocoltypes.Message { + if len(history) == 0 { + return []protocoltypes.Message{} + } + + filtered := make([]protocoltypes.Message, 0, len(history)) + for _, msg := range history { + if IsTransientAssistantThoughtMessage(msg) { + continue + } + filtered = append(filtered, msg) + } + return filtered +} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 29667cd31..be3e77a43 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -15,6 +15,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/providers/common" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -34,6 +35,7 @@ type ( type Provider struct { apiKey string apiBase string + providerName string maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client extraBody map[string]any // Additional fields to inject into request body @@ -95,6 +97,12 @@ func WithCustomHeaders(customHeaders map[string]string) Option { } } +func WithProviderName(providerName string) Option { + return func(p *Provider) { + p.providerName = strings.ToLower(strings.TrimSpace(providerName)) + } +} + func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, @@ -136,7 +144,7 @@ func (p *Provider) buildRequestBody( requestBody := map[string]any{ "model": model, - "messages": common.SerializeMessages(messages), + "messages": common.SerializeMessages(p.prepareMessagesForRequest(messages)), } // When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview. @@ -196,6 +204,115 @@ 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 + // DeepSeek thinking-mode replay only requires reasoning_content for + // turns that participate in a tool interaction round. For plain + // assistant turns between two user messages, the docs say the API will + // ignore reasoning_content on replay, so we strip it here. + if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction { + cloned.ReasoningContent = "" + } + 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, diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index d140d63d6..4f68fb393 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -202,7 +202,7 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) { } } -func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { +func TestProviderChat_StripsReasoningContentForNonDeepSeekHistory(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -225,8 +225,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { p := NewProvider("key", server.URL, "") - // Simulate a multi-turn conversation where the assistant's previous - // reply included reasoning_content (e.g. from kimi-k2.5). messages := []Message{ {Role: "user", Content: "What is 1+1?"}, {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, @@ -238,7 +236,6 @@ func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { t.Fatalf("Chat() error = %v", err) } - // Verify reasoning_content is preserved in the serialized request. reqMessages, ok := requestBody["messages"].([]any) if !ok { t.Fatalf("messages is not []any: %T", requestBody["messages"]) @@ -247,8 +244,391 @@ 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_DeepSeekDocsReplayRequirements(t *testing.T) { + // DeepSeek's thinking-mode and multi-round chat docs distinguish two cases: + // - for a plain assistant turn between two user messages without tool calls, + // reasoning_content does not need to be replayed and the API ignores it if sent; + // - for a turn that participates in a tool-interaction round, assistant + // reasoning_content must be replayed on subsequent requests. + // + // Keep this behavior explicit here so future changes do not "fix" the + // non-tool stripping based on issue reports that are broader than the + // vendor documentation. + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.SetProviderName("deepseek") + + messages := []Message{ + {Role: "user", Content: "Who wrote The Hobbit?"}, + {Role: "assistant", Content: "J.R.R. Tolkien.", ReasoningContent: "I know this from general knowledge."}, + {Role: "user", Content: "What's the weather tomorrow?"}, + { + Role: "assistant", + Content: "Let me check the date first.", + ReasoningContent: "I need tomorrow's date before checking the weather.", + ToolCalls: []ToolCall{{ + ID: "call_date", + Type: "function", + Function: &FunctionCall{ + Name: "get_date", + Arguments: "{}", + }, + }}, + }, + {Role: "tool", ToolCallID: "call_date", Content: "2026-04-29"}, + { + Role: "assistant", + Content: "Tomorrow is 2026-04-30.", + ReasoningContent: "Now I can continue with the weather request.", + }, + {Role: "user", Content: "What about Guangzhou?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "deepseek-v4-flash", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + if len(reqMessages) != len(messages) { + t.Fatalf("len(messages) = %d, want %d", len(reqMessages), len(messages)) + } + + plainAssistant, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("plain assistant message is not map[string]any: %T", reqMessages[1]) + } + if _, exists := plainAssistant["reasoning_content"]; exists { + t.Fatalf( + "plain DeepSeek turn should omit reasoning_content on replay, got %v", + plainAssistant["reasoning_content"], + ) + } + + toolAssistant, ok := reqMessages[3].(map[string]any) + if !ok { + t.Fatalf("tool assistant message is not map[string]any: %T", reqMessages[3]) + } + if toolAssistant["reasoning_content"] != "I need tomorrow's date before checking the weather." { + t.Fatalf( + "tool assistant reasoning_content = %v, want preserved", + toolAssistant["reasoning_content"], + ) + } + + finalAssistant, ok := reqMessages[5].(map[string]any) + if !ok { + t.Fatalf("final assistant message is not map[string]any: %T", reqMessages[5]) + } + if finalAssistant["reasoning_content"] != "Now I can continue with the weather request." { + t.Fatalf( + "final assistant reasoning_content = %v, want preserved", + finalAssistant["reasoning_content"], + ) } } diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 07aa086e0..34007e03f 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -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 { @@ -81,11 +88,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 { diff --git a/pkg/seahorse/schema.go b/pkg/seahorse/schema.go index aa829358b..5b67fe9e0 100644 --- a/pkg/seahorse/schema.go +++ b/pkg/seahorse/schema.go @@ -46,6 +46,7 @@ func runSchema(db *sql.DB) error { conversation_id INTEGER NOT NULL REFERENCES conversations(conversation_id), role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', + reasoning_content TEXT NOT NULL DEFAULT '', token_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) )`, @@ -157,9 +158,57 @@ func runSchema(db *sql.DB) error { return err } } + + if err := ensureMessagesReasoningContentColumn(db); err != nil { + return err + } return nil } +func ensureMessagesReasoningContentColumn(db *sql.DB) error { + hasColumn, err := tableHasColumn(db, "messages", "reasoning_content") + if err != nil { + return fmt.Errorf("check messages.reasoning_content: %w", err) + } + if hasColumn { + return nil + } + + if _, err := db.Exec(`ALTER TABLE messages ADD COLUMN reasoning_content TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add messages.reasoning_content: %w", err) + } + return nil +} + +func tableHasColumn(db *sql.DB, tableName, columnName string) (bool, error) { + rows, err := db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, tableName)) + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var ( + cid int + name string + columnType string + notNull int + defaultVal sql.NullString + pk int + ) + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultVal, &pk); err != nil { + return false, err + } + if name == columnName { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, err + } + return false, nil +} + // checkFTS5Support verifies that SQLite has FTS5 with trigram tokenizer enabled. // This is required for full-text search with CJK (Chinese, Japanese, Korean) support. func checkFTS5Support(db *sql.DB) error { diff --git a/pkg/seahorse/schema_test.go b/pkg/seahorse/schema_test.go index f3d6a3650..943b742b2 100644 --- a/pkg/seahorse/schema_test.go +++ b/pkg/seahorse/schema_test.go @@ -91,6 +91,53 @@ func TestRunMigrationsIdempotent(t *testing.T) { } } +func TestRunSchemaAddsMessagesReasoningContentColumn(t *testing.T) { + db := openTestDB(t) + + _, err := db.Exec(`CREATE TABLE messages ( + message_id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + token_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`) + if err != nil { + t.Fatalf("create legacy messages table: %v", err) + } + + err = runSchema(db) + if err != nil { + t.Fatalf("runSchema: %v", err) + } + + var count int + err = db.QueryRow(`SELECT count(*) FROM pragma_table_info('messages') WHERE name = 'reasoning_content'`). + Scan(&count) + if err != nil { + t.Fatalf("query pragma_table_info: %v", err) + } + if count != 1 { + t.Fatalf("reasoning_content column count = %d, want 1", count) + } + + _, err = db.Exec( + `INSERT INTO conversations (session_key, created_at, updated_at) VALUES (?, datetime('now'), datetime('now'))`, + "reasoning-column-test", + ) + if err != nil { + t.Fatalf("insert conversation: %v", err) + } + + _, err = db.Exec( + `INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) + VALUES (1, 'assistant', 'answer', 'thinking', 1)`, + ) + if err != nil { + t.Fatalf("insert message with reasoning_content: %v", err) + } +} + func TestMigrationConversationUnique(t *testing.T) { db := openTestDB(t) if err := runSchema(db); err != nil { diff --git a/pkg/seahorse/short_engine.go b/pkg/seahorse/short_engine.go index f584788ce..0a8175617 100644 --- a/pkg/seahorse/short_engine.go +++ b/pkg/seahorse/short_engine.go @@ -253,9 +253,23 @@ func (e *Engine) Ingest(ctx context.Context, sessionKey string, messages []Messa var added *Message var err error if len(msg.Parts) > 0 { - added, err = e.store.AddMessageWithParts(ctx, conv.ConversationID, msg.Role, msg.Parts, msg.TokenCount) + added, err = e.store.AddMessageWithPartsAndReasoning( + ctx, + conv.ConversationID, + msg.Role, + msg.Parts, + msg.ReasoningContent, + msg.TokenCount, + ) } else { - added, err = e.store.AddMessage(ctx, conv.ConversationID, msg.Role, msg.Content, msg.TokenCount) + added, err = e.store.AddMessageWithReasoning( + ctx, + conv.ConversationID, + msg.Role, + msg.Content, + msg.ReasoningContent, + msg.TokenCount, + ) } if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -420,7 +434,7 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me // Fast path: DB has same count and exact match → no-op if len(dbMsgs) == len(messages) { matched := true - for i := 0; i < len(messages); i++ { + for i := range messages { if !messageMatches(dbMsgs[i], messages[i]) { matched = false break @@ -431,14 +445,21 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me } } - // Find longest matching prefix from the start - anchor := -1 - compareLen := len(dbMsgs) - if compareLen > len(messages) { - compareLen = len(messages) + // Migration repair path: old SeaHorse rows may be missing reasoning_content + // even though the canonical JSONL history already has it. Backfill those + // rows in place so we do not treat this as edited history and leave stale + // summaries/context behind after a partial raw-message rebuild. + if repaired, err := e.repairBootstrapReasoningContent(ctx, dbMsgs, messages); err != nil { + return fmt.Errorf("bootstrap: repair reasoning_content: %w", err) + } else if repaired && len(dbMsgs) == len(messages) { + return nil } - for i := 0; i < compareLen; i++ { + // Find longest matching prefix from the start + anchor := -1 + compareLen := min(len(dbMsgs), len(messages)) + + for i := range compareLen { if messageMatches(dbMsgs[i], messages[i]) { anchor = i } else { @@ -524,6 +545,57 @@ func (e *Engine) Bootstrap(ctx context.Context, sessionKey string, messages []Me return nil } +func (e *Engine) repairBootstrapReasoningContent(ctx context.Context, dbMsgs, messages []Message) (bool, error) { + if len(dbMsgs) == 0 || len(messages) == 0 { + return false, nil + } + + overlap := min(len(messages), len(dbMsgs)) + + var updates []struct { + index int + messageID int64 + reasoningContent string + } + + for i := range overlap { + if !messageMatchesIgnoringReasoning(dbMsgs[i], messages[i]) { + return false, nil + } + if dbMsgs[i].ReasoningContent == messages[i].ReasoningContent { + continue + } + if dbMsgs[i].ReasoningContent != "" || messages[i].ReasoningContent == "" { + return false, nil + } + updates = append(updates, struct { + index int + messageID int64 + reasoningContent string + }{ + index: i, + messageID: dbMsgs[i].ID, + reasoningContent: messages[i].ReasoningContent, + }) + } + + if len(updates) == 0 { + return false, nil + } + + for _, update := range updates { + if err := e.store.UpdateMessageReasoningContent(ctx, update.messageID, update.reasoningContent); err != nil { + return false, err + } + dbMsgs[update.index].ReasoningContent = update.reasoningContent + } + + logger.InfoCF("seahorse", "bootstrap: repaired missing reasoning_content", map[string]any{ + "messages": len(updates), + }) + return true, nil +} + // truncate shortens a string for logging. func truncate(s string, maxLen int) string { if len(s) <= maxLen { @@ -532,12 +604,19 @@ func truncate(s string, maxLen int) string { return s[:maxLen] + "..." } -// messageMatches compares two messages using (role, content) or (role, parts). -// TokenCount is NOT compared because it may be re-estimated differently -// during bootstrap (e.g., via tokenizer.EstimateMessageTokens). +// messageMatches compares two messages using role + reasoning_content and then +// either content or parts. TokenCount is NOT compared because it may be +// re-estimated differently during bootstrap (e.g., via tokenizer.EstimateMessageTokens). // For messages with Parts (tool_use, tool_result), compare Parts instead of Content -// since AddMessageWithParts stores empty Content in DB. +// because structured messages are matched by their parts payload. func messageMatches(a, b Message) bool { + if a.Role != b.Role || a.ReasoningContent != b.ReasoningContent { + return false + } + return messageMatchesIgnoringReasoning(a, b) +} + +func messageMatchesIgnoringReasoning(a, b Message) bool { if a.Role != b.Role { return false } diff --git a/pkg/seahorse/short_engine_test.go b/pkg/seahorse/short_engine_test.go index d64634fb7..2a5c6c5d8 100644 --- a/pkg/seahorse/short_engine_test.go +++ b/pkg/seahorse/short_engine_test.go @@ -320,6 +320,108 @@ func TestEngineIngestWithParts(t *testing.T) { } } +func TestEngineIngestPreservesReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + { + Role: "assistant", + Content: "world", + ReasoningContent: "let me think this through", + TokenCount: 4, + }, + } + + _, err := eng.Ingest(ctx, "agent:reasoning", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:reasoning") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if stored[0].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[0].ReasoningContent = %q, want %q", + stored[0].ReasoningContent, + "let me think this through", + ) + } + + result, err := eng.Assemble(ctx, "agent:reasoning", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("assembled messages = %d, want 1", len(result.Messages)) + } + if result.Messages[0].ReasoningContent != "let me think this through" { + t.Errorf( + "assembled reasoning = %q, want %q", + result.Messages[0].ReasoningContent, + "let me think this through", + ) + } +} + +func TestEngineIngestWithPartsPreservesReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + + msgs := []Message{ + { + Role: "assistant", + ReasoningContent: "I need to inspect the file first", + TokenCount: 10, + Parts: []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + }, + }, + } + + _, err := eng.Ingest(ctx, "agent:parts-reasoning", msgs) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + conv, _ := eng.store.GetOrCreateConversation(ctx, "agent:parts-reasoning") + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 1 { + t.Fatalf("stored messages = %d, want 1", len(stored)) + } + if stored[0].ReasoningContent != "I need to inspect the file first" { + t.Errorf( + "stored reasoning = %q, want %q", + stored[0].ReasoningContent, + "I need to inspect the file first", + ) + } + + result, err := eng.Assemble(ctx, "agent:parts-reasoning", AssembleInput{Budget: 1000}) + if err != nil { + t.Fatalf("Assemble: %v", err) + } + if len(result.Messages) != 1 { + t.Fatalf("assembled messages = %d, want 1", len(result.Messages)) + } + if result.Messages[0].ReasoningContent != "I need to inspect the file first" { + t.Errorf( + "assembled reasoning = %q, want %q", + result.Messages[0].ReasoningContent, + "I need to inspect the file first", + ) + } +} + func TestEngineIngestAssemblePreservesParts(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() @@ -514,6 +616,216 @@ func TestEngineBootstrapIdempotent(t *testing.T) { } } +func TestBootstrapRepairsMissingReasoningContent(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } +} + +func TestBootstrapRepairsMissingReasoningContentWithoutDroppingSummaries(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning-summary" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + summary, err := eng.store.CreateSummary(ctx, CreateSummaryInput{ + ConversationID: conv.ConversationID, + Kind: SummaryKindLeaf, + Depth: 0, + Content: "summary before repair", + TokenCount: 10, + }) + if err != nil { + t.Fatalf("CreateSummary: %v", err) + } + + err = eng.store.AppendContextSummary(ctx, conv.ConversationID, summary.SummaryID) + if err != nil { + t.Fatalf("AppendContextSummary: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 2 { + t.Fatalf("stored messages = %d, want 2", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } + + summaries, err := eng.store.GetSummariesByConversation(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetSummariesByConversation: %v", err) + } + if len(summaries) != 1 { + t.Fatalf("summaries = %d, want 1", len(summaries)) + } + if summaries[0].SummaryID != summary.SummaryID { + t.Errorf("SummaryID = %q, want %q", summaries[0].SummaryID, summary.SummaryID) + } + + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("context items = %d, want 3", len(items)) + } + if items[2].ItemType != "summary" || items[2].SummaryID != summary.SummaryID { + t.Errorf("summary context item = %+v, want summary %q", items[2], summary.SummaryID) + } +} + +func TestBootstrapRepairsMissingReasoningContentOnPrefixBeforeAppendingDelta(t *testing.T) { + eng := newTestEngine(t) + ctx := context.Background() + sessionKey := "agent:repair-reasoning-prefix" + + conv, err := eng.store.GetOrCreateConversation(ctx, sessionKey) + if err != nil { + t.Fatalf("GetOrCreateConversation: %v", err) + } + + userMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "user", "hello", 3) + if err != nil { + t.Fatalf("AddMessage user: %v", err) + } + assistantMsg, err := eng.store.AddMessage(ctx, conv.ConversationID, "assistant", "world", 3) + if err != nil { + t.Fatalf("AddMessage assistant: %v", err) + } + + err = eng.store.AppendContextMessages( + ctx, + conv.ConversationID, + []int64{userMsg.ID, assistantMsg.ID}, + ) + if err != nil { + t.Fatalf("AppendContextMessages: %v", err) + } + + err = eng.Bootstrap(ctx, sessionKey, []Message{ + {Role: "user", Content: "hello", TokenCount: 3}, + {Role: "assistant", Content: "world", ReasoningContent: "let me think this through", TokenCount: 3}, + {Role: "user", Content: "follow-up", TokenCount: 2}, + }) + if err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + stored, err := eng.store.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(stored) != 3 { + t.Fatalf("stored messages = %d, want 3", len(stored)) + } + if stored[1].ReasoningContent != "let me think this through" { + t.Errorf( + "stored[1].ReasoningContent = %q, want %q", + stored[1].ReasoningContent, + "let me think this through", + ) + } + if stored[2].Content != "follow-up" { + t.Errorf("stored[2].Content = %q, want %q", stored[2].Content, "follow-up") + } + + items, err := eng.store.GetContextItems(ctx, conv.ConversationID) + if err != nil { + t.Fatalf("GetContextItems: %v", err) + } + if len(items) != 3 { + t.Fatalf("context items = %d, want 3", len(items)) + } + if items[2].ItemType != "message" || items[2].MessageID != stored[2].ID { + t.Errorf("last context item = %+v, want appended message %d", items[2], stored[2].ID) + } +} + func TestEngineBootstrapDelta(t *testing.T) { eng := newTestEngine(t) ctx := context.Background() diff --git a/pkg/seahorse/store.go b/pkg/seahorse/store.go index c84aaaf07..0edbbd128 100644 --- a/pkg/seahorse/store.go +++ b/pkg/seahorse/store.go @@ -162,20 +162,31 @@ func (s *Store) getMessageTimeRange(ctx context.Context, convID int64) (time.Tim // AddMessage appends a message to a conversation. func (s *Store) AddMessage(ctx context.Context, convID int64, role, content string, tokenCount int) (*Message, error) { + return s.AddMessageWithReasoning(ctx, convID, role, content, "", tokenCount) +} + +// AddMessageWithReasoning appends a message with reasoning content to a conversation. +func (s *Store) AddMessageWithReasoning( + ctx context.Context, + convID int64, + role, content, reasoningContent string, + tokenCount int, +) (*Message, error) { result, err := s.db.ExecContext(ctx, - "INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)", - convID, role, content, tokenCount, + "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", + convID, role, content, reasoningContent, tokenCount, ) if err != nil { return nil, fmt.Errorf("add message: %w", err) } id, _ := result.LastInsertId() return &Message{ - ID: id, - ConversationID: convID, - Role: role, - Content: content, - TokenCount: tokenCount, + ID: id, + ConversationID: convID, + Role: role, + Content: content, + ReasoningContent: reasoningContent, + TokenCount: tokenCount, }, nil } @@ -212,6 +223,18 @@ func (s *Store) AddMessageWithParts( role string, parts []MessagePart, tokenCount int, +) (*Message, error) { + return s.AddMessageWithPartsAndReasoning(ctx, convID, role, parts, "", tokenCount) +} + +// AddMessageWithPartsAndReasoning adds a message with structured parts and reasoning content. +func (s *Store) AddMessageWithPartsAndReasoning( + ctx context.Context, + convID int64, + role string, + parts []MessagePart, + reasoningContent string, + tokenCount int, ) (*Message, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -223,8 +246,8 @@ func (s *Store) AddMessageWithParts( readableContent := partsToReadableContent(parts) result, err := tx.ExecContext(ctx, - "INSERT INTO messages (conversation_id, role, content, token_count) VALUES (?, ?, ?, ?)", - convID, role, readableContent, tokenCount, + "INSERT INTO messages (conversation_id, role, content, reasoning_content, token_count) VALUES (?, ?, ?, ?, ?)", + convID, role, readableContent, reasoningContent, tokenCount, ) if err != nil { return nil, fmt.Errorf("add message: %w", err) @@ -256,11 +279,12 @@ func (s *Store) AddMessageWithParts( // Return message with parts msg := &Message{ - ID: msgID, - ConversationID: convID, - Role: role, - TokenCount: tokenCount, - Parts: make([]MessagePart, len(parts)), + ID: msgID, + ConversationID: convID, + Role: role, + ReasoningContent: reasoningContent, + TokenCount: tokenCount, + Parts: make([]MessagePart, len(parts)), } for i, p := range parts { p.MessageID = msgID @@ -271,7 +295,7 @@ func (s *Store) AddMessageWithParts( // GetMessages retrieves messages for a conversation. func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, beforeID int64) ([]Message, error) { - query := "SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE conversation_id = ?" + query := "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE conversation_id = ?" args := []any{convID} if beforeID > 0 { query += " AND message_id < ?" @@ -298,6 +322,7 @@ func (s *Store) GetMessages(ctx context.Context, convID int64, limit int, before &msg.ConversationID, &msg.Role, &msg.Content, + &msg.ReasoningContent, &msg.TokenCount, &createdAt, ); err != nil { @@ -335,10 +360,11 @@ func (s *Store) GetMessageCount(ctx context.Context, convID int64) (int, error) func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, error) { var msg Message var createdAt string - err := s.db.QueryRowContext(ctx, - "SELECT message_id, conversation_id, role, content, token_count, created_at FROM messages WHERE message_id = ?", + err := s.db.QueryRowContext( + ctx, + "SELECT message_id, conversation_id, role, content, reasoning_content, token_count, created_at FROM messages WHERE message_id = ?", messageID, - ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.TokenCount, &createdAt) + ).Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &msg.ReasoningContent, &msg.TokenCount, &createdAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("message %d not found", messageID) } @@ -350,6 +376,28 @@ func (s *Store) GetMessageByID(ctx context.Context, messageID int64) (*Message, return &msg, nil } +// UpdateMessageReasoningContent updates reasoning_content for an existing message. +func (s *Store) UpdateMessageReasoningContent(ctx context.Context, messageID int64, reasoningContent string) error { + result, err := s.db.ExecContext( + ctx, + "UPDATE messages SET reasoning_content = ? WHERE message_id = ?", + reasoningContent, + messageID, + ) + if err != nil { + return fmt.Errorf("update message reasoning_content: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("update message reasoning_content rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("message %d not found", messageID) + } + return nil +} + func (s *Store) loadMessageParts(ctx context.Context, msgID int64) ([]MessagePart, error) { rows, err := s.db.QueryContext(ctx, `SELECT part_id, message_id, type, text, name, arguments, tool_call_id, media_uri, mime_type @@ -534,7 +582,7 @@ func (s *Store) LinkSummaryToMessages(ctx context.Context, summaryID string, mes // GetSummarySourceMessages retrieves source messages for a summary. func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) ([]Message, error) { rows, err := s.db.QueryContext(ctx, - `SELECT m.message_id, m.conversation_id, m.role, m.content, m.token_count, m.created_at + `SELECT m.message_id, m.conversation_id, m.role, m.content, m.reasoning_content, m.token_count, m.created_at FROM summary_messages sm JOIN messages m ON m.message_id = sm.message_id WHERE sm.summary_id = ? @@ -555,6 +603,7 @@ func (s *Store) GetSummarySourceMessages(ctx context.Context, summaryID string) &msg.ConversationID, &msg.Role, &msg.Content, + &msg.ReasoningContent, &msg.TokenCount, &createdAt, ); err != nil { diff --git a/pkg/seahorse/store_test.go b/pkg/seahorse/store_test.go index 89635cc9a..67bed1c11 100644 --- a/pkg/seahorse/store_test.go +++ b/pkg/seahorse/store_test.go @@ -199,6 +199,47 @@ func TestStoreAddAndGetMessages(t *testing.T) { } } +func TestStoreAddAndGetMessagesWithReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:reasoning") + + msg, err := s.AddMessageWithReasoning( + ctx, + conv.ConversationID, + "assistant", + "hello world", + "let me think", + 5, + ) + if err != nil { + t.Fatalf("AddMessageWithReasoning: %v", err) + } + if msg.ReasoningContent != "let me think" { + t.Fatalf("ReasoningContent = %q, want %q", msg.ReasoningContent, "let me think") + } + + msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].ReasoningContent != "let me think" { + t.Errorf("ReasoningContent = %q, want %q", msgs[0].ReasoningContent, "let me think") + } + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.ReasoningContent != "let me think" { + t.Errorf("GetMessageByID ReasoningContent = %q, want %q", found.ReasoningContent, "let me think") + } +} + func TestStoreAddMessageWithParts(t *testing.T) { s := openTestStore(t) ctx := context.Background() @@ -233,6 +274,43 @@ func TestStoreAddMessageWithParts(t *testing.T) { } } +func TestStoreAddMessageWithPartsAndReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:parts-reasoning") + + parts := []MessagePart{ + {Type: "tool_use", Name: "read_file", Arguments: `{"path":"/tmp/test"}`, ToolCallID: "tc_123"}, + } + _, err := s.AddMessageWithPartsAndReasoning( + ctx, + conv.ConversationID, + "assistant", + parts, + "need to inspect the file first", + 10, + ) + if err != nil { + t.Fatalf("AddMessageWithPartsAndReasoning: %v", err) + } + + msgs, err := s.GetMessages(ctx, conv.ConversationID, 10, 0) + if err != nil { + t.Fatalf("GetMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + if msgs[0].ReasoningContent != "need to inspect the file first" { + t.Errorf( + "ReasoningContent = %q, want %q", + msgs[0].ReasoningContent, + "need to inspect the file first", + ) + } +} + func TestStoreGetMessageCount(t *testing.T) { s := openTestStore(t) ctx := context.Background() @@ -275,6 +353,31 @@ func TestStoreGetMessageByID(t *testing.T) { } } +func TestStoreUpdateMessageReasoningContent(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + conv, _ := s.GetOrCreateConversation(ctx, "agent:update-reasoning") + + msg, err := s.AddMessage(ctx, conv.ConversationID, "assistant", "answer", 3) + if err != nil { + t.Fatalf("AddMessage: %v", err) + } + + err = s.UpdateMessageReasoningContent(ctx, msg.ID, "thinking") + if err != nil { + t.Fatalf("UpdateMessageReasoningContent: %v", err) + } + + found, err := s.GetMessageByID(ctx, msg.ID) + if err != nil { + t.Fatalf("GetMessageByID: %v", err) + } + if found.ReasoningContent != "thinking" { + t.Errorf("ReasoningContent = %q, want %q", found.ReasoningContent, "thinking") + } +} + // --- Summary Operations --- func TestStoreCreateAndGetSummary(t *testing.T) { diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 7f87d460a..1d6fa3106 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" ) type Session struct { @@ -69,6 +70,10 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) { // AddFullMessage adds a complete message with tool calls and tool call ID to the session. // This is used to save the full conversation flow including tool calls and tool results. func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { + if messageutil.IsTransientAssistantThoughtMessage(msg) { + return + } + sm.mu.Lock() defer sm.mu.Unlock() @@ -196,8 +201,7 @@ func (sm *SessionManager) Save(key string) error { Updated: stored.Updated, } if len(stored.Messages) > 0 { - snapshot.Messages = make([]providers.Message, len(stored.Messages)) - copy(snapshot.Messages, stored.Messages) + snapshot.Messages = messageutil.FilterInvalidHistoryMessages(stored.Messages) } else { snapshot.Messages = []providers.Message{} } @@ -270,6 +274,7 @@ func (sm *SessionManager) loadSessions() error { if err := json.Unmarshal(data, &session); err != nil { continue } + session.Messages = messageutil.FilterInvalidHistoryMessages(session.Messages) sm.sessions[session.Key] = &session } @@ -290,6 +295,7 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) { session, ok := sm.sessions[key] if ok { + history = messageutil.FilterInvalidHistoryMessages(history) // Create a deep copy to strictly isolate internal state // from the caller's slice. msgs := make([]providers.Message, len(history)) diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index f2e6561df..a9547eba9 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -357,7 +357,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } if response != "" { - t.executor.PublishResponseIfNeeded(ctx, channel, chatID, "", response) + t.executor.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response) } return "ok" } diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index d46d365a0..0e527c98a 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -24,6 +24,7 @@ type stubJobExecutor struct { publishedResp string publishedChan string publishedChatID string + publishedKey string } func (s *stubJobExecutor) ProcessDirectWithChannel( @@ -47,6 +48,7 @@ func (s *stubJobExecutor) PublishResponseIfNeeded( s.publishedResp = response s.publishedChan = channel s.publishedChatID = chatID + s.publishedKey = sessionKey } func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { @@ -283,6 +285,9 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { if executor.publishedResp != "generated reply" { t.Fatalf("published response = %q, want generated reply", executor.publishedResp) } + if executor.publishedKey != executor.lastKey { + t.Fatalf("published sessionKey = %q, want %q", executor.publishedKey, executor.lastKey) + } if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID) } diff --git a/pkg/tools/facade_compat_test.go b/pkg/tools/facade_compat_test.go index 672554209..378462512 100644 --- a/pkg/tools/facade_compat_test.go +++ b/pkg/tools/facade_compat_test.go @@ -9,6 +9,9 @@ func TestFacadeConstructorsRemainAvailable(t *testing.T) { if NewSPITool() == nil { t.Fatal("NewSPITool should return a tool") } + if NewSerialTool() == nil { + t.Fatal("NewSerialTool should return a tool") + } if NewMessageTool() == nil { t.Fatal("NewMessageTool should return a tool") } diff --git a/pkg/tools/fs/load_image.go b/pkg/tools/fs/load_image.go index 6f612faea..0a67fa120 100644 --- a/pkg/tools/fs/load_image.go +++ b/pkg/tools/fs/load_image.go @@ -147,10 +147,10 @@ func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolR return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err)) } - // Build the tool result text. The media:// ref will be picked up by - // resolveMediaRefs in loop_media.go and converted to a base64 data URL - // before the next LLM call, exactly like channel-received images. - msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref) + // Build the tool result text. The media:// ref in Media will be picked + // up by resolveMediaRefs in agent_media.go and base64-encoded for tool + // result messages (role="tool"), so the LLM can see the image content. + msg := fmt.Sprintf("Image loaded: %s\n[image: photo]", filename) return &ToolResult{ ForLLM: msg, diff --git a/pkg/tools/fs/load_image_test.go b/pkg/tools/fs/load_image_test.go index 72f163d81..d33db73be 100644 --- a/pkg/tools/fs/load_image_test.go +++ b/pkg/tools/fs/load_image_test.go @@ -135,9 +135,10 @@ func TestLoadImage_SuccessPath(t *testing.T) { t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM) } - // 4. ForLLM should also contain the media:// ref - if !strings.Contains(result.ForLLM, result.Media[0]) { - t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM) + // 4. ForLLM should contain the generic [image: photo] placeholder + // (resolveMediaRefs will replace it with the actual path later) + if !strings.Contains(result.ForLLM, "[image: photo]") { + t.Errorf("expected ForLLM to contain '[image: photo]' placeholder, got: %s", result.ForLLM) } // 5. Verify the ref is resolvable in the store diff --git a/pkg/tools/hardware/serial.go b/pkg/tools/hardware/serial.go new file mode 100644 index 000000000..7e197a909 --- /dev/null +++ b/pkg/tools/hardware/serial.go @@ -0,0 +1,453 @@ +package hardwaretools + +import ( + "context" + "encoding/json" + "fmt" + "math" + "regexp" + "runtime" + "strings" + "time" + "unicode/utf8" +) + +const ( + defaultSerialBaud = 115200 + defaultSerialDataBits = 8 + defaultSerialStopBits = 1 + defaultSerialTimeoutMS = 1000 + maxSerialPayloadBytes = 4096 + maxSerialReadBytes = 4096 + serialPollInterval = 100 * time.Millisecond +) + +var ( + unixSerialPortPattern = regexp.MustCompile( + `^(?:/dev/)?(?:ttyS\d+|ttyUSB\d+|ttyACM\d+|ttyAMA\d+|rfcomm\d+|tty\.[A-Za-z0-9._-]+|cu\.[A-Za-z0-9._-]+)$`, + ) + windowsSerialPortPattern = regexp.MustCompile(`^(?:\\\\\.\\)?COM[1-9]\d*$`) + unixSerialBaudRates = map[int]struct{}{ + 50: {}, 75: {}, 110: {}, 134: {}, 150: {}, 200: {}, 300: {}, 600: {}, 1200: {}, 1800: {}, + 2400: {}, 4800: {}, 9600: {}, 19200: {}, 38400: {}, 57600: {}, 115200: {}, 230400: {}, + } +) + +type SerialTool struct{} + +type serialPortInfo struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type serialConfig struct { + Port string + Baud int + DataBits int + Parity string + StopBits int +} + +func NewSerialTool() *SerialTool { + return &SerialTool{} +} + +func (t *SerialTool) Name() string { + return "serial" +} + +func (t *SerialTool) Description() string { + return "Interact with host serial ports. Actions: list (enumerate ports), read (receive bytes), write (send bytes with explicit confirmation). Supports Linux, macOS, and Windows." +} + +func (t *SerialTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"list", "read", "write"}, + "description": "Action to perform: list available serial ports, read bytes from a port, or write bytes to a port.", + }, + "port": map[string]any{ + "type": "string", + "description": "Serial port path or name, for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3. Required for read/write.", + }, + "baud": map[string]any{ + "type": "integer", + "description": "Baud rate. Default: 115200. Linux/macOS currently support standard termios rates up to 230400; Windows accepts configured rates up to 4000000.", + }, + "data_bits": map[string]any{ + "type": "integer", + "description": "Data bits. Supported values: 5, 6, 7, 8. Default: 8.", + }, + "parity": map[string]any{ + "type": "string", + "enum": []string{"none", "even", "odd"}, + "description": "Parity mode. Default: none.", + }, + "stop_bits": map[string]any{ + "type": "integer", + "description": "Stop bits. Supported values: 1, 2. Default: 1.", + }, + "timeout_ms": map[string]any{ + "type": "integer", + "description": "Read/write timeout in milliseconds. Default: 1000.", + }, + "length": map[string]any{ + "type": "integer", + "description": "Number of bytes to read. Required for read. Range: 1-4096.", + }, + "data": map[string]any{ + "type": "array", + "items": map[string]any{"type": "integer"}, + "description": "Bytes to write, each in range 0-255. Required for write unless text is provided.", + }, + "text": map[string]any{ + "type": "string", + "description": "UTF-8 text to write. Required for write if data is omitted.", + }, + "confirm": map[string]any{ + "type": "boolean", + "description": "Must be true for write operations.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *SerialTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, ok := args["action"].(string) + if !ok || strings.TrimSpace(action) == "" { + return ErrorResult("action is required") + } + + switch action { + case "list": + return t.list() + case "read": + return t.read(ctx, args) + case "write": + return t.write(ctx, args) + default: + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, read, write)", action)) + } +} + +func (t *SerialTool) list() *ToolResult { + ports, err := serialListPorts() + if err != nil { + return ErrorResult(fmt.Sprintf("failed to list serial ports: %v", err)) + } + if len(ports) == 0 { + return SilentResult("No serial ports found on this host.") + } + + result, _ := json.MarshalIndent(map[string]any{ + "ports": ports, + "count": len(ports), + }, "", " ") + return SilentResult(string(result)) +} + +func (t *SerialTool) read(ctx context.Context, args map[string]any) *ToolResult { + cfg, errResult := parseSerialConfig(args) + if errResult != nil { + return errResult + } + + length := 0 + if v, ok := args["length"].(float64); ok { + length = int(v) + } + if length < 1 || length > maxSerialReadBytes { + return ErrorResult(fmt.Sprintf("length is required for read (1-%d)", maxSerialReadBytes)) + } + + timeout, errResult := parseSerialTimeout(args) + if errResult != nil { + return errResult + } + + data, err := serialRead(ctx, cfg, length, timeout) + if err != nil { + return ErrorResult(fmt.Sprintf("serial read failed on %s: %v", cfg.Port, err)) + } + + return SilentResult(formatSerialPayload("read", cfg, data, timeout)) +} + +func (t *SerialTool) write(ctx context.Context, args map[string]any) *ToolResult { + confirm, _ := args["confirm"].(bool) + if !confirm { + return ErrorResult( + "write operations require confirm: true. Please confirm with the user before sending bytes to a serial device.", + ) + } + + cfg, errResult := parseSerialConfig(args) + if errResult != nil { + return errResult + } + timeout, errResult := parseSerialTimeout(args) + if errResult != nil { + return errResult + } + payload, errResult := parseSerialWritePayload(args) + if errResult != nil { + return errResult + } + + written, err := serialWrite(ctx, cfg, payload, timeout) + if err != nil { + return ErrorResult(fmt.Sprintf("serial write failed on %s: %v", cfg.Port, err)) + } + + result, _ := json.MarshalIndent(map[string]any{ + "action": "write", + "port": cfg.Port, + "baud": cfg.Baud, + "data_bits": cfg.DataBits, + "parity": cfg.Parity, + "stop_bits": cfg.StopBits, + "timeout_ms": timeout.Milliseconds(), + "written": written, + "payload": serialPayloadSummary(payload), + }, "", " ") + return SilentResult(string(result)) +} + +func parseSerialConfig(args map[string]any) (serialConfig, *ToolResult) { + port, ok := args["port"].(string) + port = strings.TrimSpace(port) + if !ok || port == "" { + return serialConfig{}, ErrorResult( + "port is required (for example /dev/ttyUSB0, /dev/cu.usbserial-0001, or COM3)", + ) + } + + normalizedPort, err := normalizeSerialPort(port) + if err != nil { + return serialConfig{}, ErrorResult(err.Error()) + } + + cfg := serialConfig{ + Port: normalizedPort, + Baud: defaultSerialBaud, + DataBits: defaultSerialDataBits, + Parity: "none", + StopBits: defaultSerialStopBits, + } + + if v, ok := args["baud"].(float64); ok { + cfg.Baud = int(v) + } + if err := validateSerialBaud(cfg.Baud); err != nil { + return serialConfig{}, ErrorResult(err.Error()) + } + + if v, ok := args["data_bits"].(float64); ok { + cfg.DataBits = int(v) + } + switch cfg.DataBits { + case 5, 6, 7, 8: + default: + return serialConfig{}, ErrorResult("data_bits must be one of 5, 6, 7, or 8") + } + + if v, ok := args["parity"].(string); ok && strings.TrimSpace(v) != "" { + cfg.Parity = strings.ToLower(strings.TrimSpace(v)) + } + switch cfg.Parity { + case "none", "even", "odd": + default: + return serialConfig{}, ErrorResult(`parity must be one of "none", "even", or "odd"`) + } + + if v, ok := args["stop_bits"].(float64); ok { + cfg.StopBits = int(v) + } + if cfg.StopBits != 1 && cfg.StopBits != 2 { + return serialConfig{}, ErrorResult("stop_bits must be 1 or 2") + } + + return cfg, nil +} + +func parseSerialTimeout(args map[string]any) (time.Duration, *ToolResult) { + timeoutMS := defaultSerialTimeoutMS + if v, ok := args["timeout_ms"].(float64); ok { + timeoutMS = int(v) + } + if timeoutMS < 1 || timeoutMS > 60000 { + return 0, ErrorResult("timeout_ms must be between 1 and 60000") + } + return time.Duration(timeoutMS) * time.Millisecond, nil +} + +func parseSerialWritePayload(args map[string]any) ([]byte, *ToolResult) { + if text, ok := args["text"].(string); ok && text != "" { + if !utf8.ValidString(text) { + return nil, ErrorResult("text must be valid UTF-8") + } + if len(text) > maxSerialPayloadBytes { + return nil, ErrorResult(fmt.Sprintf("text payload too large: maximum %d bytes", maxSerialPayloadBytes)) + } + return []byte(text), nil + } + + dataRaw, ok := args["data"].([]any) + if !ok || len(dataRaw) == 0 { + return nil, ErrorResult("write requires either text or data") + } + if len(dataRaw) > maxSerialPayloadBytes { + return nil, ErrorResult(fmt.Sprintf("data too long: maximum %d bytes", maxSerialPayloadBytes)) + } + + data := make([]byte, len(dataRaw)) + for i, v := range dataRaw { + f, ok := v.(float64) + if !ok { + return nil, ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i)) + } + if f != math.Trunc(f) { + return nil, ErrorResult(fmt.Sprintf("data[%d] is not an integer byte value", i)) + } + b := int(f) + if b < 0 || b > 255 { + return nil, ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b)) + } + data[i] = byte(b) + } + + return data, nil +} + +func formatSerialPayload(action string, cfg serialConfig, data []byte, timeout time.Duration) string { + result, _ := json.MarshalIndent(map[string]any{ + "action": action, + "port": cfg.Port, + "baud": cfg.Baud, + "data_bits": cfg.DataBits, + "parity": cfg.Parity, + "stop_bits": cfg.StopBits, + "timeout_ms": timeout.Milliseconds(), + "payload": serialPayloadSummary(data), + }, "", " ") + return string(result) +} + +func serialPayloadSummary(data []byte) map[string]any { + hexValues := make([]string, len(data)) + intValues := make([]int, len(data)) + for i, b := range data { + hexValues[i] = fmt.Sprintf("0x%02x", b) + intValues[i] = int(b) + } + + summary := map[string]any{ + "length": len(data), + "bytes": intValues, + "hex": hexValues, + } + if utf8.Valid(data) { + summary["text"] = string(data) + } + return summary +} + +func normalizeSerialPort(port string) (string, error) { + switch runtime.GOOS { + case "windows": + return normalizeWindowsSerialPath(port) + case "linux", "darwin": + return normalizeUnixSerialPath(port) + default: + if normalized, err := normalizeUnixSerialPath(port); err == nil { + return normalized, nil + } + return normalizeWindowsSerialPath(port) + } +} + +func normalizeUnixSerialPath(port string) (string, error) { + trimmed := strings.TrimSpace(port) + if !unixSerialPortPattern.MatchString(trimmed) { + return "", fmt.Errorf( + "invalid serial port: expected a safe Unix device name such as /dev/ttyUSB0 or /dev/cu.usbserial-0001", + ) + } + if strings.HasPrefix(trimmed, "/dev/") { + return trimmed, nil + } + return "/dev/" + trimmed, nil +} + +func normalizeWindowsSerialPath(port string) (string, error) { + trimmed := strings.ToUpper(strings.TrimSpace(port)) + if !windowsSerialPortPattern.MatchString(trimmed) { + return "", fmt.Errorf("invalid serial port: expected a COM port such as COM3") + } + if strings.HasPrefix(trimmed, `\\.\`) { + return trimmed, nil + } + return `\\.\` + trimmed, nil +} + +func validateSerialBaud(baud int) error { + if baud < 50 || baud > 4000000 { + return fmt.Errorf("baud must be between 50 and 4000000") + } + + switch runtime.GOOS { + case "linux", "darwin": + if _, ok := unixSerialBaudRates[baud]; !ok { + return fmt.Errorf("unsupported baud rate on this platform: %d (supported up to 230400)", baud) + } + } + + return nil +} + +func serialContextErr(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} + +func serialWriteAll( + ctx context.Context, + data []byte, + timeout time.Duration, + now func() time.Time, + write func([]byte) (int, error), +) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + total := 0 + deadline := now().Add(timeout) + for total < len(data) { + if err := serialContextErr(ctx); err != nil { + return total, err + } + if deadline.Sub(now()) <= 0 { + return total, fmt.Errorf("timeout while writing serial data") + } + + n, err := write(data[total:]) + total += n + if err != nil { + return total, err + } + if n == 0 { + continue + } + } + + return total, nil +} diff --git a/pkg/tools/hardware/serial_darwin.go b/pkg/tools/hardware/serial_darwin.go new file mode 100644 index 000000000..bc019029e --- /dev/null +++ b/pkg/tools/hardware/serial_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package hardwaretools + +import "golang.org/x/sys/unix" + +func serialGetTermios(fd int) (*unix.Termios, error) { + return unix.IoctlGetTermios(fd, unix.TIOCGETA) +} + +func serialSetSpeed(tio *unix.Termios, speed uint32) error { + tio.Ispeed = uint64(speed) + tio.Ospeed = uint64(speed) + return nil +} + +func serialSetTermios(fd int, tio *unix.Termios) error { + return unix.IoctlSetTermios(fd, unix.TIOCSETA, tio) +} diff --git a/pkg/tools/hardware/serial_linux.go b/pkg/tools/hardware/serial_linux.go new file mode 100644 index 000000000..bad3e4cb8 --- /dev/null +++ b/pkg/tools/hardware/serial_linux.go @@ -0,0 +1,19 @@ +//go:build linux + +package hardwaretools + +import "golang.org/x/sys/unix" + +func serialGetTermios(fd int) (*unix.Termios, error) { + return unix.IoctlGetTermios(fd, unix.TCGETS) +} + +func serialSetSpeed(tio *unix.Termios, speed uint32) error { + tio.Ispeed = speed + tio.Ospeed = speed + return nil +} + +func serialSetTermios(fd int, tio *unix.Termios) error { + return unix.IoctlSetTermios(fd, unix.TCSETS, tio) +} diff --git a/pkg/tools/hardware/serial_other.go b/pkg/tools/hardware/serial_other.go new file mode 100644 index 000000000..ec72a2d2a --- /dev/null +++ b/pkg/tools/hardware/serial_other.go @@ -0,0 +1,21 @@ +//go:build !linux && !darwin && !windows + +package hardwaretools + +import ( + "context" + "fmt" + "time" +) + +func serialListPorts() ([]serialPortInfo, error) { + return nil, fmt.Errorf("serial is not supported on this platform") +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + return nil, fmt.Errorf("serial is not supported on this platform") +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + return 0, fmt.Errorf("serial is not supported on this platform") +} diff --git a/pkg/tools/hardware/serial_other_test.go b/pkg/tools/hardware/serial_other_test.go new file mode 100644 index 000000000..ef04c4062 --- /dev/null +++ b/pkg/tools/hardware/serial_other_test.go @@ -0,0 +1,18 @@ +//go:build !linux && !darwin && !windows + +package hardwaretools + +import ( + "strings" + "testing" +) + +func TestSerialListPortsUnsupportedPlatform(t *testing.T) { + _, err := serialListPorts() + if err == nil { + t.Fatal("expected unsupported platform error") + } + if !strings.Contains(err.Error(), "not supported") { + t.Fatalf("serialListPorts() error = %v, want unsupported platform message", err) + } +} diff --git a/pkg/tools/hardware/serial_test.go b/pkg/tools/hardware/serial_test.go new file mode 100644 index 000000000..6b2e9765d --- /dev/null +++ b/pkg/tools/hardware/serial_test.go @@ -0,0 +1,269 @@ +package hardwaretools + +import ( + "context" + "runtime" + "strings" + "testing" + "time" +) + +func TestParseSerialConfig(t *testing.T) { + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + cfg, errResult := parseSerialConfig(map[string]any{ + "port": port, + "baud": float64(9600), + "data_bits": float64(7), + "parity": "even", + "stop_bits": float64(2), + }) + if errResult != nil { + t.Fatalf("parseSerialConfig() unexpected error = %v", errResult.ForLLM) + } + + wantPort := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + wantPort = `\\.\COM3` + } + if cfg.Port != wantPort || cfg.Baud != 9600 || cfg.DataBits != 7 || cfg.Parity != "even" || cfg.StopBits != 2 { + t.Fatalf("parseSerialConfig() = %#v", cfg) + } +} + +func TestParseSerialConfigRejectsInvalidParity(t *testing.T) { + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, errResult := parseSerialConfig(map[string]any{ + "port": port, + "parity": "mark", + }) + if errResult == nil { + t.Fatal("expected invalid parity to fail") + } +} + +func TestParseSerialConfigRejectsUnsupportedUnixBaud(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("Unix baud validation only applies on Unix platforms") + } + + _, errResult := parseSerialConfig(map[string]any{ + "port": "/dev/ttyUSB0", + "baud": float64(460800), + }) + if errResult == nil { + t.Fatal("expected unsupported Unix baud rate to fail") + } +} + +func TestParseSerialWritePayloadRejectsFractionalBytes(t *testing.T) { + _, errResult := parseSerialWritePayload(map[string]any{ + "data": []any{65.9}, + }) + if errResult == nil { + t.Fatal("expected fractional byte value to fail") + } +} + +func TestValidateSerialBaud(t *testing.T) { + tests := []struct { + name string + baud int + wantErr bool + }{ + {name: "default-supported", baud: 115200}, + {name: "max-unix-supported", baud: 230400}, + {name: "too-low", baud: 49, wantErr: true}, + {name: "too-high", baud: 4000001, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSerialBaud(tt.baud) + if (err != nil) != tt.wantErr { + t.Fatalf("validateSerialBaud(%d) error = %v, wantErr %v", tt.baud, err, tt.wantErr) + } + }) + } +} + +func TestSerialReadCanceledBeforeOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, err := serialRead( + ctx, + serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1}, + 1, + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("serialRead() error = %v, want context canceled", err) + } +} + +func TestSerialWriteCanceledBeforeOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + port := "/dev/ttyUSB0" + if runtime.GOOS == "windows" { + port = "COM3" + } + + _, err := serialWrite( + ctx, + serialConfig{Port: port, Baud: 115200, DataBits: 8, Parity: "none", StopBits: 1}, + []byte("AT"), + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("serialWrite() error = %v, want context canceled", err) + } +} + +func TestParseSerialConfigRejectsUnsafePortPaths(t *testing.T) { + tests := []string{ + "../../../etc/passwd", + "/etc/passwd", + `C:\temp\device.txt`, + `\\.\C:\temp\device.txt`, + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) { + _, errResult := parseSerialConfig(map[string]any{ + "port": port, + }) + if errResult == nil { + t.Fatalf("expected unsafe port %q to be rejected", port) + } + }) + } +} + +func TestNormalizeUnixSerialPath(t *testing.T) { + tests := []struct { + port string + want string + }{ + {port: "ttyUSB0", want: "/dev/ttyUSB0"}, + {port: "/dev/ttyACM0", want: "/dev/ttyACM0"}, + {port: "/dev/cu.usbserial-0001", want: "/dev/cu.usbserial-0001"}, + } + + for _, tt := range tests { + got, err := normalizeUnixSerialPath(tt.port) + if err != nil { + t.Fatalf("normalizeUnixSerialPath(%q) unexpected error = %v", tt.port, err) + } + if got != tt.want { + t.Fatalf("normalizeUnixSerialPath(%q) = %q, want %q", tt.port, got, tt.want) + } + } +} + +func TestNormalizeUnixSerialPathRejectsInvalidNames(t *testing.T) { + tests := []string{ + "", + "ttyUSB0/../../passwd", + "/dev/../../etc/passwd", + "/tmp/ttyUSB0", + "ttyUSB", + "COM3", + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(port, "/", "_"), func(t *testing.T) { + if _, err := normalizeUnixSerialPath(port); err == nil { + t.Fatalf("expected %q to be rejected", port) + } + }) + } +} + +func TestNormalizeWindowsSerialPath(t *testing.T) { + tests := []struct { + port string + want string + }{ + {port: "COM3", want: `\\.\COM3`}, + {port: "com12", want: `\\.\COM12`}, + {port: `\\.\COM7`, want: `\\.\COM7`}, + } + + for _, tt := range tests { + got, err := normalizeWindowsSerialPath(tt.port) + if err != nil { + t.Fatalf("normalizeWindowsSerialPath(%q) unexpected error = %v", tt.port, err) + } + if got != tt.want { + t.Fatalf("normalizeWindowsSerialPath(%q) = %q, want %q", tt.port, got, tt.want) + } + } +} + +func TestNormalizeWindowsSerialPathRejectsInvalidNames(t *testing.T) { + tests := []string{ + "", + "COM0", + "COM", + "/dev/ttyUSB0", + `C:\temp\device.txt`, + `\\.\C:\temp\device.txt`, + `\\server\share\COM3`, + } + + for _, port := range tests { + t.Run(strings.ReplaceAll(strings.ReplaceAll(port, `\`, "_"), "/", "_"), func(t *testing.T) { + if _, err := normalizeWindowsSerialPath(port); err == nil { + t.Fatalf("expected %q to be rejected", port) + } + }) + } +} + +func TestParseSerialTimeout(t *testing.T) { + timeout, errResult := parseSerialTimeout(map[string]any{ + "timeout_ms": float64(2500), + }) + if errResult != nil { + t.Fatalf("parseSerialTimeout() unexpected error = %v", errResult.ForLLM) + } + if timeout != 2500*time.Millisecond { + t.Fatalf("timeout = %v, want 2500ms", timeout) + } +} + +func TestParseSerialWritePayloadSupportsText(t *testing.T) { + data, errResult := parseSerialWritePayload(map[string]any{ + "text": "AT\r\n", + }) + if errResult != nil { + t.Fatalf("parseSerialWritePayload() unexpected error = %v", errResult.ForLLM) + } + if string(data) != "AT\r\n" { + t.Fatalf("payload = %q, want %q", string(data), "AT\r\n") + } +} + +func TestParseSerialWritePayloadRejectsOutOfRangeByte(t *testing.T) { + _, errResult := parseSerialWritePayload(map[string]any{ + "data": []any{float64(256)}, + }) + if errResult == nil { + t.Fatal("expected payload validation failure") + } +} diff --git a/pkg/tools/hardware/serial_unix.go b/pkg/tools/hardware/serial_unix.go new file mode 100644 index 000000000..548b8573b --- /dev/null +++ b/pkg/tools/hardware/serial_unix.go @@ -0,0 +1,286 @@ +//go:build linux || darwin + +package hardwaretools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "golang.org/x/sys/unix" +) + +var ( + unixSerialNow = time.Now + unixSerialOpenPort = openAndConfigureSerialPort + unixSerialClosePort = unix.Close + unixSerialPollRead = pollRead + unixSerialPollWrite = pollWrite +) + +func serialListPorts() ([]serialPortInfo, error) { + patterns := []string{ + "/dev/ttyS*", + "/dev/ttyUSB*", + "/dev/ttyACM*", + "/dev/ttyAMA*", + "/dev/rfcomm*", + "/dev/tty.*", + "/dev/cu.*", + } + + seen := make(map[string]struct{}) + ports := make([]serialPortInfo, 0) + for _, pattern := range patterns { + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + for _, match := range matches { + if _, ok := seen[match]; ok { + continue + } + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + seen[match] = struct{}{} + ports = append(ports, serialPortInfo{ + Name: filepath.Base(match), + Path: match, + }) + } + } + + sort.Slice(ports, func(i, j int) bool { + return ports[i].Path < ports[j].Path + }) + return ports, nil +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + fd, err := unixSerialOpenPort(cfg) + if err != nil { + return nil, err + } + defer unixSerialClosePort(fd) + + buf := make([]byte, length) + total := 0 + deadline := unixSerialNow().Add(timeout) + + for total < length { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + remaining := deadline.Sub(unixSerialNow()) + if remaining <= 0 { + break + } + + n, err := unixSerialPollRead(fd, buf[total:], minSerialPollTimeout(remaining)) + if err != nil { + return nil, err + } + if n == 0 { + continue + } + total += n + } + + return buf[:total], nil +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + fd, err := unixSerialOpenPort(cfg) + if err != nil { + return 0, err + } + defer unixSerialClosePort(fd) + + total := 0 + deadline := unixSerialNow().Add(timeout) + for total < len(data) { + if err := serialContextErr(ctx); err != nil { + return total, err + } + + remaining := deadline.Sub(unixSerialNow()) + if remaining <= 0 { + return total, fmt.Errorf("timeout while writing serial data") + } + + n, err := unixSerialPollWrite(fd, data[total:], minSerialPollTimeout(remaining)) + if err != nil { + return total, err + } + if n == 0 { + continue + } + total += n + } + + return total, nil +} + +func openAndConfigureSerialPort(cfg serialConfig) (int, error) { + fd, err := unix.Open(cfg.Port, unix.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0) + if err != nil { + return -1, err + } + + if err := unix.SetNonblock(fd, false); err != nil { + unix.Close(fd) + return -1, err + } + + if err := configureUnixSerialPort(fd, cfg); err != nil { + unix.Close(fd) + return -1, err + } + + return fd, nil +} + +func configureUnixSerialPort(fd int, cfg serialConfig) error { + tio, err := serialGetTermios(fd) + if err != nil { + return err + } + + tio.Iflag = 0 + tio.Oflag = 0 + tio.Lflag = 0 + tio.Cflag = unix.CREAD | unix.CLOCAL + tio.Cc[unix.VMIN] = 0 + tio.Cc[unix.VTIME] = 0 + + switch cfg.DataBits { + case 5: + tio.Cflag |= unix.CS5 + case 6: + tio.Cflag |= unix.CS6 + case 7: + tio.Cflag |= unix.CS7 + default: + tio.Cflag |= unix.CS8 + } + + switch cfg.Parity { + case "even": + tio.Cflag |= unix.PARENB + case "odd": + tio.Cflag |= unix.PARENB | unix.PARODD + } + + if cfg.StopBits == 2 { + tio.Cflag |= unix.CSTOPB + } + + speed, err := serialBaudToUnix(cfg.Baud) + if err != nil { + return err + } + if err := serialSetSpeed(tio, speed); err != nil { + return err + } + + return serialSetTermios(fd, tio) +} + +func serialBaudToUnix(baud int) (uint32, error) { + switch baud { + case 50: + return unix.B50, nil + case 75: + return unix.B75, nil + case 110: + return unix.B110, nil + case 134: + return unix.B134, nil + case 150: + return unix.B150, nil + case 200: + return unix.B200, nil + case 300: + return unix.B300, nil + case 600: + return unix.B600, nil + case 1200: + return unix.B1200, nil + case 1800: + return unix.B1800, nil + case 2400: + return unix.B2400, nil + case 4800: + return unix.B4800, nil + case 9600: + return unix.B9600, nil + case 19200: + return unix.B19200, nil + case 38400: + return unix.B38400, nil + case 57600: + return unix.B57600, nil + case 115200: + return unix.B115200, nil + case 230400: + return unix.B230400, nil + default: + return 0, fmt.Errorf("unsupported baud rate on this platform: %d", baud) + } +} + +func pollRead(fd int, dst []byte, timeout time.Duration) (int, error) { + pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} + n, err := unix.Poll(pfd, durationToPollTimeout(timeout)) + if err != nil { + return 0, err + } + if n == 0 { + return 0, nil + } + return unix.Read(fd, dst) +} + +func pollWrite(fd int, src []byte, timeout time.Duration) (int, error) { + pfd := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}} + n, err := unix.Poll(pfd, durationToPollTimeout(timeout)) + if err != nil { + return 0, err + } + if n == 0 { + return 0, nil + } + return unix.Write(fd, src) +} + +func durationToPollTimeout(timeout time.Duration) int { + if timeout <= 0 { + return 0 + } + ms := int(timeout / time.Millisecond) + if ms == 0 { + return 1 + } + return ms +} + +func minSerialPollTimeout(timeout time.Duration) time.Duration { + if timeout > serialPollInterval { + return serialPollInterval + } + return timeout +} diff --git a/pkg/tools/hardware/serial_unix_test.go b/pkg/tools/hardware/serial_unix_test.go new file mode 100644 index 000000000..fac2efe7f --- /dev/null +++ b/pkg/tools/hardware/serial_unix_test.go @@ -0,0 +1,140 @@ +//go:build linux || darwin + +package hardwaretools + +import ( + "context" + "errors" + "testing" + "time" +) + +func stubUnixSerialIO(t *testing.T, now *time.Time) { + t.Helper() + + prevNow := unixSerialNow + prevOpen := unixSerialOpenPort + prevClose := unixSerialClosePort + prevPollRead := unixSerialPollRead + prevPollWrite := unixSerialPollWrite + + unixSerialNow = func() time.Time { + return *now + } + unixSerialOpenPort = func(cfg serialConfig) (int, error) { + return 42, nil + } + unixSerialClosePort = func(fd int) error { + return nil + } + unixSerialPollRead = prevPollRead + unixSerialPollWrite = prevPollWrite + + t.Cleanup(func() { + unixSerialNow = prevNow + unixSerialOpenPort = prevOpen + unixSerialClosePort = prevClose + unixSerialPollRead = prevPollRead + unixSerialPollWrite = prevPollWrite + }) +} + +func TestSerialReadWaitsPastEmptyPollsUntilDeadline(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + pollCalls := 0 + unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) { + pollCalls++ + if timeout > serialPollInterval { + t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval) + } + now = now.Add(timeout) + if pollCalls < 4 { + return 0, nil + } + return copy(dst, []byte("OK")), nil + } + + got, err := serialRead(context.Background(), serialConfig{}, 2, 500*time.Millisecond) + if err != nil { + t.Fatalf("serialRead() error = %v", err) + } + if string(got) != "OK" { + t.Fatalf("serialRead() = %q, want %q", got, "OK") + } + if pollCalls != 4 { + t.Fatalf("poll calls = %d, want 4", pollCalls) + } +} + +func TestSerialReadReturnsPromptlyOnContextCancelBetweenPolls(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + ctx, cancel := context.WithCancel(context.Background()) + pollCalls := 0 + unixSerialPollRead = func(fd int, dst []byte, timeout time.Duration) (int, error) { + pollCalls++ + now = now.Add(timeout) + cancel() + return 0, nil + } + + _, err := serialRead(ctx, serialConfig{}, 1, time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("serialRead() error = %v, want context canceled", err) + } + if pollCalls != 1 { + t.Fatalf("poll calls = %d, want 1", pollCalls) + } +} + +func TestSerialWriteWaitsPastEmptyPollsUntilReady(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + pollCalls := 0 + unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) { + pollCalls++ + if timeout > serialPollInterval { + t.Fatalf("poll timeout = %v, want <= %v", timeout, serialPollInterval) + } + now = now.Add(timeout) + switch pollCalls { + case 1, 2: + return 0, nil + default: + return 1, nil + } + } + + written, err := serialWrite(context.Background(), serialConfig{}, []byte("OK"), 500*time.Millisecond) + if err != nil { + t.Fatalf("serialWrite() error = %v", err) + } + if written != 2 { + t.Fatalf("serialWrite() wrote %d bytes, want 2", written) + } + if pollCalls != 4 { + t.Fatalf("poll calls = %d, want 4", pollCalls) + } +} + +func TestSerialWriteTimesOutAfterRepeatedEmptyPolls(t *testing.T) { + now := time.Unix(0, 0) + stubUnixSerialIO(t, &now) + + unixSerialPollWrite = func(fd int, src []byte, timeout time.Duration) (int, error) { + now = now.Add(timeout) + return 0, nil + } + + written, err := serialWrite(context.Background(), serialConfig{}, []byte("A"), 250*time.Millisecond) + if err == nil || err.Error() != "timeout while writing serial data" { + t.Fatalf("serialWrite() error = %v, want timeout", err) + } + if written != 0 { + t.Fatalf("serialWrite() wrote %d bytes, want 0", written) + } +} diff --git a/pkg/tools/hardware/serial_windows.go b/pkg/tools/hardware/serial_windows.go new file mode 100644 index 000000000..31a215589 --- /dev/null +++ b/pkg/tools/hardware/serial_windows.go @@ -0,0 +1,247 @@ +//go:build windows + +package hardwaretools + +import ( + "context" + "sort" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetCommState = kernel32.NewProc("GetCommState") + procSetCommState = kernel32.NewProc("SetCommState") + procSetCommTimeouts = kernel32.NewProc("SetCommTimeouts") + procPurgeComm = kernel32.NewProc("PurgeComm") +) + +const ( + purgeTxClear = 0x0004 + purgeRxClear = 0x0008 + + dcbFlagBinary = 0x00000001 + dcbFlagParity = 0x00000002 + dcbFlagOutxCtsFlow = 0x00000004 + dcbFlagOutxDsrFlow = 0x00000008 + dcbFlagDtrControlMask = 0x00000030 + dcbFlagDsrSensitivity = 0x00000040 + dcbFlagTXContinueOnXoff = 0x00000080 + dcbFlagOutX = 0x00000100 + dcbFlagInX = 0x00000200 + dcbFlagRtsControlMask = 0x00003000 +) + +type dcb struct { + DCBlength uint32 + BaudRate uint32 + Flags uint32 + Reserved uint16 + XonLim uint16 + XoffLim uint16 + ByteSize byte + Parity byte + StopBits byte + XonChar byte + XoffChar byte + ErrorChar byte + EofChar byte + EvtChar byte + wReserved1 uint16 +} + +type commTimeouts struct { + ReadIntervalTimeout uint32 + ReadTotalTimeoutMultiplier uint32 + ReadTotalTimeoutConstant uint32 + WriteTotalTimeoutMultiplier uint32 + WriteTotalTimeoutConstant uint32 +} + +func serialListPorts() ([]serialPortInfo, error) { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DEVICEMAP\SERIALCOMM`, registry.QUERY_VALUE) + if err != nil { + if err == registry.ErrNotExist { + return nil, nil + } + return nil, err + } + defer key.Close() + + names, err := key.ReadValueNames(-1) + if err != nil { + return nil, err + } + + ports := make([]serialPortInfo, 0, len(names)) + seen := make(map[string]struct{}) + for _, name := range names { + value, _, err := key.GetStringValue(name) + if err != nil { + continue + } + portName := strings.TrimSpace(value) + if portName == "" { + continue + } + normalized := strings.ToUpper(portName) + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + ports = append(ports, serialPortInfo{ + Name: normalized, + Path: normalized, + }) + } + + sort.Slice(ports, func(i, j int) bool { + return ports[i].Path < ports[j].Path + }) + return ports, nil +} + +func serialRead(ctx context.Context, cfg serialConfig, length int, timeout time.Duration) ([]byte, error) { + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + handle, err := openAndConfigureWindowsSerial(cfg, timeout) + if err != nil { + return nil, err + } + defer windows.CloseHandle(handle) + + if err := serialContextErr(ctx); err != nil { + return nil, err + } + + buf := make([]byte, length) + var read uint32 + // Synchronous serial I/O on Windows cannot be interrupted once the syscall starts. + // COMMTIMEOUTS bounds how long turn cancellation may take to surface. + if err := windows.ReadFile(handle, buf, &read, nil); err != nil { + return nil, err + } + return buf[:read], nil +} + +func serialWrite(ctx context.Context, cfg serialConfig, data []byte, timeout time.Duration) (int, error) { + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + handle, err := openAndConfigureWindowsSerial(cfg, timeout) + if err != nil { + return 0, err + } + defer windows.CloseHandle(handle) + + if err := serialContextErr(ctx); err != nil { + return 0, err + } + + return serialWriteAll(ctx, data, timeout, time.Now, func(chunk []byte) (int, error) { + var written uint32 + // Like ReadFile above, this synchronous WriteFile call relies on COMMTIMEOUTS + // rather than context preemption once the syscall is in flight. + if err := windows.WriteFile(handle, chunk, &written, nil); err != nil { + return int(written), err + } + return int(written), nil + }) +} + +func openAndConfigureWindowsSerial(cfg serialConfig, timeout time.Duration) (windows.Handle, error) { + handle, err := windows.CreateFile( + windows.StringToUTF16Ptr(cfg.Port), + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, + nil, + windows.OPEN_EXISTING, + 0, + 0, + ) + if err != nil { + return 0, err + } + + if err := configureWindowsSerialPort(handle, cfg, timeout); err != nil { + windows.CloseHandle(handle) + return 0, err + } + return handle, nil +} + +func configureWindowsSerialPort(handle windows.Handle, cfg serialConfig, timeout time.Duration) error { + state := dcb{DCBlength: uint32(unsafe.Sizeof(dcb{}))} + r1, _, err := procGetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state))) + if r1 == 0 { + return err + } + + state.BaudRate = uint32(cfg.Baud) + state.ByteSize = byte(cfg.DataBits) + state.Flags = sanitizeWindowsSerialFlags(state.Flags) + state.Flags |= dcbFlagBinary + + switch cfg.Parity { + case "even": + state.Parity = 2 + state.Flags |= dcbFlagParity + case "odd": + state.Parity = 1 + state.Flags |= dcbFlagParity + default: + state.Parity = 0 + state.Flags &^= dcbFlagParity + } + + switch cfg.StopBits { + case 2: + state.StopBits = 2 + default: + state.StopBits = 0 + } + + r1, _, err = procSetCommState.Call(uintptr(handle), uintptr(unsafe.Pointer(&state))) + if r1 == 0 { + return err + } + + timeoutMS := uint32(timeout / time.Millisecond) + if timeoutMS == 0 { + timeoutMS = 1 + } + timeouts := commTimeouts{ + ReadIntervalTimeout: timeoutMS, + ReadTotalTimeoutConstant: timeoutMS, + WriteTotalTimeoutConstant: timeoutMS, + ReadTotalTimeoutMultiplier: 0, + WriteTotalTimeoutMultiplier: 0, + } + r1, _, err = procSetCommTimeouts.Call(uintptr(handle), uintptr(unsafe.Pointer(&timeouts))) + if r1 == 0 { + return err + } + + procPurgeComm.Call(uintptr(handle), uintptr(purgeRxClear|purgeTxClear)) + return nil +} + +func sanitizeWindowsSerialFlags(flags uint32) uint32 { + flags &^= dcbFlagOutxCtsFlow | + dcbFlagOutxDsrFlow | + dcbFlagDtrControlMask | + dcbFlagDsrSensitivity | + dcbFlagTXContinueOnXoff | + dcbFlagOutX | + dcbFlagInX | + dcbFlagRtsControlMask + return flags +} diff --git a/pkg/tools/hardware/serial_windows_test.go b/pkg/tools/hardware/serial_windows_test.go new file mode 100644 index 000000000..ecb0addbd --- /dev/null +++ b/pkg/tools/hardware/serial_windows_test.go @@ -0,0 +1,39 @@ +//go:build windows + +package hardwaretools + +import "testing" + +func TestSanitizeWindowsSerialFlags(t *testing.T) { + flags := uint32( + dcbFlagBinary | + dcbFlagParity | + dcbFlagOutxCtsFlow | + dcbFlagOutxDsrFlow | + dcbFlagDtrControlMask | + dcbFlagDsrSensitivity | + dcbFlagTXContinueOnXoff | + dcbFlagOutX | + dcbFlagInX | + dcbFlagRtsControlMask, + ) + + got := sanitizeWindowsSerialFlags(flags) + + if got&dcbFlagBinary == 0 { + t.Fatal("sanitizeWindowsSerialFlags() should preserve fBinary") + } + if got&dcbFlagParity == 0 { + t.Fatal("sanitizeWindowsSerialFlags() should preserve fParity") + } + if got&(dcbFlagOutxCtsFlow| + dcbFlagOutxDsrFlow| + dcbFlagDtrControlMask| + dcbFlagDsrSensitivity| + dcbFlagTXContinueOnXoff| + dcbFlagOutX| + dcbFlagInX| + dcbFlagRtsControlMask) != 0 { + t.Fatalf("sanitizeWindowsSerialFlags() = %#x, want flow-control bits cleared", got) + } +} diff --git a/pkg/tools/hardware/serial_write_common_test.go b/pkg/tools/hardware/serial_write_common_test.go new file mode 100644 index 000000000..398c1fde5 --- /dev/null +++ b/pkg/tools/hardware/serial_write_common_test.go @@ -0,0 +1,87 @@ +package hardwaretools + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSerialWriteAllRetriesPartialWritesUntilComplete(t *testing.T) { + now := time.Unix(0, 0) + calls := 0 + + written, err := serialWriteAll(context.Background(), []byte("PING"), time.Second, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + switch calls { + case 1: + if string(chunk) != "PING" { + t.Fatalf("first chunk = %q, want %q", chunk, "PING") + } + return 2, nil + case 2: + if string(chunk) != "NG" { + t.Fatalf("second chunk = %q, want %q", chunk, "NG") + } + return 2, nil + default: + t.Fatalf("unexpected extra write call %d", calls) + return 0, nil + } + }) + if err != nil { + t.Fatalf("serialWriteAll() error = %v", err) + } + if written != 4 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 4", written) + } +} + +func TestSerialWriteAllTimesOutAfterZeroByteWrites(t *testing.T) { + now := time.Unix(0, 0) + calls := 0 + + written, err := serialWriteAll(context.Background(), []byte("A"), 250*time.Millisecond, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + return 0, nil + }) + if err == nil || err.Error() != "timeout while writing serial data" { + t.Fatalf("serialWriteAll() error = %v, want timeout", err) + } + if written != 0 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written) + } + if calls != 3 { + t.Fatalf("write calls = %d, want 3", calls) + } +} + +func TestSerialWriteAllReturnsContextCancellationAfterRetryBoundary(t *testing.T) { + now := time.Unix(0, 0) + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + + written, err := serialWriteAll(ctx, []byte("A"), time.Second, func() time.Time { + return now + }, func(chunk []byte) (int, error) { + calls++ + now = now.Add(100 * time.Millisecond) + cancel() + return 0, nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("serialWriteAll() error = %v, want context canceled", err) + } + if written != 0 { + t.Fatalf("serialWriteAll() wrote %d bytes, want 0", written) + } + if calls != 1 { + t.Fatalf("write calls = %d, want 1", calls) + } +} diff --git a/pkg/tools/hardware_facade.go b/pkg/tools/hardware_facade.go index f55d152cf..b505c5a48 100644 --- a/pkg/tools/hardware_facade.go +++ b/pkg/tools/hardware_facade.go @@ -3,8 +3,9 @@ package tools import hardwaretools "github.com/sipeed/picoclaw/pkg/tools/hardware" type ( - I2CTool = hardwaretools.I2CTool - SPITool = hardwaretools.SPITool + I2CTool = hardwaretools.I2CTool + SerialTool = hardwaretools.SerialTool + SPITool = hardwaretools.SPITool ) func NewI2CTool() *I2CTool { @@ -14,3 +15,7 @@ func NewI2CTool() *I2CTool { func NewSPITool() *SPITool { return hardwaretools.NewSPITool() } + +func NewSerialTool() *SerialTool { + return hardwaretools.NewSerialTool() +} diff --git a/pkg/tools/integration/mcp_tool.go b/pkg/tools/integration/mcp_tool.go index 340bb9e8e..78c348316 100644 --- a/pkg/tools/integration/mcp_tool.go +++ b/pkg/tools/integration/mcp_tool.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" ) // MCPManager defines the interface for MCP manager operations @@ -161,6 +162,14 @@ func (t *MCPTool) Description() string { return fmt.Sprintf("[MCP:%s] %s", t.serverName, desc) } +func (t *MCPTool) PromptMetadata() toolshared.PromptMetadata { + return toolshared.PromptMetadata{ + Layer: toolshared.ToolPromptLayerCapability, + Slot: toolshared.ToolPromptSlotMCP, + Source: "mcp:" + sanitizeIdentifierComponent(t.serverName), + } +} + // Parameters returns the tool parameters schema func (t *MCPTool) Parameters() map[string]any { // The InputSchema is already a JSON Schema object diff --git a/pkg/tools/integration/mcp_tool_test.go b/pkg/tools/integration/mcp_tool_test.go index e5c54abb6..7b0b2cd5a 100644 --- a/pkg/tools/integration/mcp_tool_test.go +++ b/pkg/tools/integration/mcp_tool_test.go @@ -11,6 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/media" + toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" ) // MockMCPManager is a mock implementation of MCPManager interface for testing @@ -104,6 +105,22 @@ func TestMCPTool_Name(t *testing.T) { } } +func TestMCPTool_PromptMetadata(t *testing.T) { + manager := &MockMCPManager{} + tool := NewMCPTool(manager, "GitHub Server", &mcp.Tool{Name: "create_issue"}) + + metadata := tool.PromptMetadata() + if metadata.Layer != toolshared.ToolPromptLayerCapability { + t.Fatalf("metadata.Layer = %q, want %q", metadata.Layer, toolshared.ToolPromptLayerCapability) + } + if metadata.Slot != toolshared.ToolPromptSlotMCP { + t.Fatalf("metadata.Slot = %q, want %q", metadata.Slot, toolshared.ToolPromptSlotMCP) + } + if metadata.Source != "mcp:github_server" { + t.Fatalf("metadata.Source = %q, want mcp:github_server", metadata.Source) + } +} + // TestMCPTool_Description verifies tool description generation func TestMCPTool_Description(t *testing.T) { tests := []struct { diff --git a/pkg/tools/integration/web.go b/pkg/tools/integration/web.go index 56663ecda..75821e40d 100644 --- a/pkg/tools/integration/web.go +++ b/pkg/tools/integration/web.go @@ -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) { diff --git a/pkg/tools/integration/web_test.go b/pkg/tools/integration/web_test.go index d47d8e7c9..ba6b3da45 100644 --- a/pkg/tools/integration/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -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") } } diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go index b05a22fe2..193ecd6f5 100644 --- a/pkg/tools/integration_facade.go +++ b/pkg/tools/integration_facade.go @@ -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) } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index e51dff71a..0ff9293a3 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -352,6 +352,7 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { name, _ := fn["name"].(string) desc, _ := fn["description"].(string) params, _ := fn["parameters"].(map[string]any) + metadata := promptMetadataForTool(entry.Tool) definitions = append(definitions, providers.ToolDefinition{ Type: "function", @@ -360,11 +361,35 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { Description: desc, Parameters: params, }, + PromptLayer: metadata.Layer, + PromptSlot: metadata.Slot, + PromptSource: metadata.Source, }) } return definitions } +func promptMetadataForTool(tool Tool) PromptMetadata { + metadata := PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceRegistry, + } + if provider, ok := tool.(PromptMetadataProvider); ok { + provided := provider.PromptMetadata() + if provided.Layer != "" { + metadata.Layer = provided.Layer + } + if provided.Slot != "" { + metadata.Slot = provided.Slot + } + if provided.Source != "" { + metadata.Source = provided.Source + } + } + return metadata +} + // List returns a list of all registered tool names. func (r *ToolRegistry) List() []string { r.mu.RLock() diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 16bd30928..eac96382f 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -39,6 +39,15 @@ func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *T return m.result } +type mockPromptMetadataTool struct { + mockRegistryTool + metadata PromptMetadata +} + +func (m *mockPromptMetadataTool) PromptMetadata() PromptMetadata { + return m.metadata +} + type mockAsyncRegistryTool struct { mockRegistryTool lastCB AsyncCallback @@ -375,6 +384,47 @@ func TestToolToSchema(t *testing.T) { } } +func TestToolRegistry_ToProviderDefsAttachesPromptMetadata(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("native", "native tool")) + r.Register(&mockPromptMetadataTool{ + mockRegistryTool: mockRegistryTool{ + name: "mcp_demo", + desc: "mcp tool", + params: map[string]any{"type": "object"}, + }, + metadata: PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotMCP, + Source: "mcp:demo", + }, + }) + + defs := r.ToProviderDefs() + if len(defs) != 2 { + t.Fatalf("ToProviderDefs() len = %d, want 2", len(defs)) + } + + byName := make(map[string]providers.ToolDefinition, len(defs)) + for _, def := range defs { + byName[def.Function.Name] = def + } + + native := byName["native"] + if native.PromptLayer != ToolPromptLayerCapability || + native.PromptSlot != ToolPromptSlotTooling || + native.PromptSource != ToolPromptSourceRegistry { + t.Fatalf("native prompt metadata = %#v, want default tooling source", native) + } + + mcp := byName["mcp_demo"] + if mcp.PromptLayer != ToolPromptLayerCapability || + mcp.PromptSlot != ToolPromptSlotMCP || + mcp.PromptSource != "mcp:demo" { + t.Fatalf("mcp prompt metadata = %#v, want mcp source", mcp) + } +} + func TestToolRegistry_Clone(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("read_file", "reads files")) diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go index f41c80d90..c5884c9de 100644 --- a/pkg/tools/search_tool.go +++ b/pkg/tools/search_tool.go @@ -34,6 +34,14 @@ func (t *RegexSearchTool) Description() string { return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools." } +func (t *RegexSearchTool) PromptMetadata() PromptMetadata { + return PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceDiscovery, + } +} + func (t *RegexSearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", @@ -95,6 +103,14 @@ func (t *BM25SearchTool) Description() string { return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools." } +func (t *BM25SearchTool) PromptMetadata() PromptMetadata { + return PromptMetadata{ + Layer: ToolPromptLayerCapability, + Slot: ToolPromptSlotTooling, + Source: ToolPromptSourceDiscovery, + } +} + func (t *BM25SearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", diff --git a/pkg/tools/shared/base.go b/pkg/tools/shared/base.go index 5498d24ab..298e1b478 100644 --- a/pkg/tools/shared/base.go +++ b/pkg/tools/shared/base.go @@ -14,6 +14,24 @@ type Tool interface { Execute(ctx context.Context, args map[string]any) *ToolResult } +const ( + ToolPromptLayerCapability = "capability" + ToolPromptSlotTooling = "tooling" + ToolPromptSlotMCP = "mcp" + ToolPromptSourceRegistry = "tool_registry:native" + ToolPromptSourceDiscovery = "tool_registry:discovery" +) + +type PromptMetadata struct { + Layer string + Slot string + Source string +} + +type PromptMetadataProvider interface { + PromptMetadata() PromptMetadata +} + // --- Request-scoped tool context (channel / chatID) --- // // Carried via context.Value so that concurrent tool calls each receive diff --git a/pkg/tools/shared_facade.go b/pkg/tools/shared_facade.go index 6e40e4e3a..8409ea060 100644 --- a/pkg/tools/shared_facade.go +++ b/pkg/tools/shared_facade.go @@ -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 { diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index 1a8b6c747..de7cb467e 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -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. diff --git a/pkg/utils/tool_feedback_dedupe.go b/pkg/utils/tool_feedback_dedupe.go new file mode 100644 index 000000000..b1adb60eb --- /dev/null +++ b/pkg/utils/tool_feedback_dedupe.go @@ -0,0 +1,39 @@ +package utils + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func normalizeToolFeedbackComparisonText(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + text = strings.TrimSpace(text) + if text == "" { + return "" + } + return strings.Join(strings.Fields(text), " ") +} + +func ToolCallExplanationDuplicatesContent(content string, toolCalls []providers.ToolCall) bool { + normalizedContent := normalizeToolFeedbackComparisonText(content) + if normalizedContent == "" || len(toolCalls) == 0 { + return false + } + + for _, tc := range toolCalls { + if tc.ExtraContent == nil { + continue + } + explanation := normalizeToolFeedbackComparisonText(tc.ExtraContent.ToolFeedbackExplanation) + if explanation == "" { + continue + } + if explanation == normalizedContent { + return true + } + } + + return false +} diff --git a/pkg/utils/tool_feedback_dedupe_test.go b/pkg/utils/tool_feedback_dedupe_test.go new file mode 100644 index 000000000..cc587080f --- /dev/null +++ b/pkg/utils/tool_feedback_dedupe_test.go @@ -0,0 +1,55 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestToolCallExplanationDuplicatesContent(t *testing.T) { + t.Run("exact duplicate", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }} + + if !ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) { + t.Fatal("expected duplicated content to be detected") + } + }) + + t.Run("whitespace normalized duplicate", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file\nbefore replying.", + }, + }} + + if !ToolCallExplanationDuplicatesContent(" Read the file before replying. ", toolCalls) { + t.Fatal("expected whitespace-only differences to be ignored") + } + }) + + t.Run("distinct content", func(t *testing.T) { + toolCalls := []providers.ToolCall{{ + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }} + + if ToolCallExplanationDuplicatesContent( + "I will summarize the findings after reading the file.", + toolCalls, + ) { + t.Fatal("expected distinct content to remain visible") + } + }) + + t.Run("missing explanation", func(t *testing.T) { + toolCalls := []providers.ToolCall{{}} + if ToolCallExplanationDuplicatesContent("Read the file before replying.", toolCalls) { + t.Fatal("expected empty tool explanations to skip dedupe") + } + }) +} diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index 316ce2408..c30f53827 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -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.", diff --git a/pkg/utils/visible_tool_calls.go b/pkg/utils/visible_tool_calls.go new file mode 100644 index 000000000..8c4d89a51 --- /dev/null +++ b/pkg/utils/visible_tool_calls.go @@ -0,0 +1,106 @@ +package utils + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +type VisibleToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function *VisibleToolCallFunction `json:"function,omitempty"` + ExtraContent *VisibleToolCallExtraContent `json:"extra_content,omitempty"` +} + +type VisibleToolCallFunction struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type VisibleToolCallExtraContent struct { + ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` +} + +func BuildVisibleToolCalls( + toolCalls []providers.ToolCall, + maxArgsLen int, +) []VisibleToolCall { + if len(toolCalls) == 0 { + return nil + } + + visible := make([]VisibleToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + name, _ := VisibleToolCallNameAndArguments(tc) + argsPreview := VisibleToolCallArgumentsPreview(tc, maxArgsLen) + explanation := "" + if tc.ExtraContent != nil { + explanation = strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation) + } + if name == "" && explanation == "" && argsPreview == "" { + continue + } + + visibleCall := VisibleToolCall{ + ID: strings.TrimSpace(tc.ID), + Type: strings.TrimSpace(tc.Type), + } + if visibleCall.Type == "" { + visibleCall.Type = "function" + } + if name != "" || argsPreview != "" { + visibleCall.Function = &VisibleToolCallFunction{ + Name: name, + Arguments: argsPreview, + } + } + if explanation != "" { + visibleCall.ExtraContent = &VisibleToolCallExtraContent{ + ToolFeedbackExplanation: explanation, + } + } + + visible = append(visible, visibleCall) + } + + if len(visible) == 0 { + return nil + } + return visible +} + +func VisibleToolCallNameAndArguments(tc providers.ToolCall) (string, string) { + name := strings.TrimSpace(tc.Name) + argsJSON := "" + if tc.Function != nil { + if name == "" { + name = strings.TrimSpace(tc.Function.Name) + } + argsJSON = strings.TrimSpace(tc.Function.Arguments) + } + if argsJSON == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + return name, strings.TrimSpace(argsJSON) +} + +func VisibleToolCallArgumentsPreview(tc providers.ToolCall, maxLen int) string { + _, argsJSON := VisibleToolCallNameAndArguments(tc) + if argsJSON == "" { + return "" + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, []byte(argsJSON), "", " "); err == nil { + argsJSON = pretty.String() + } + if maxLen > 0 { + return Truncate(argsJSON, maxLen) + } + return argsJSON +} diff --git a/pkg/utils/visible_tool_calls_test.go b/pkg/utils/visible_tool_calls_test.go new file mode 100644 index 000000000..fe9467c57 --- /dev/null +++ b/pkg/utils/visible_tool_calls_test.go @@ -0,0 +1,33 @@ +package utils + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestBuildVisibleToolCalls_DoesNotTruncateExplanation(t *testing.T) { + explanation := "Read README.md first to confirm the current project structure before editing the config example." + toolCalls := []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, + }} + + visible := BuildVisibleToolCalls(toolCalls, 20) + if len(visible) != 1 { + t.Fatalf("len(visible) = %d, want 1", len(visible)) + } + if visible[0].ExtraContent == nil || visible[0].ExtraContent.ToolFeedbackExplanation != explanation { + t.Fatalf("visible explanation = %#v, want %q", visible[0].ExtraContent, explanation) + } + if visible[0].Function == nil || visible[0].Function.Arguments == "" { + t.Fatalf("visible function = %#v, want truncated args preview", visible[0].Function) + } +} diff --git a/scripts/copydir.go b/scripts/copydir.go new file mode 100644 index 000000000..6e2777612 --- /dev/null +++ b/scripts/copydir.go @@ -0,0 +1,186 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" +) + +func main() { + if len(os.Args) != 3 { + fmt.Fprintf(os.Stderr, "usage: go run scripts/copydir.go \n") + os.Exit(2) + } + + repoRoot, err := findRepoRoot() + if err != nil { + fmt.Fprintf(os.Stderr, "locate repo root: %v\n", err) + os.Exit(1) + } + + src, err := normalizePathArg(os.Args[1], repoRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve src path: %v\n", err) + os.Exit(1) + } + + dst, err := normalizePathArg(os.Args[2], repoRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve dst path: %v\n", err) + os.Exit(1) + } + + if err := ensurePathWithinRepo(repoRoot, src); err != nil { + fmt.Fprintf(os.Stderr, "invalid src path: %v\n", err) + os.Exit(1) + } + if err := ensurePathWithinRepo(repoRoot, dst); err != nil { + fmt.Fprintf(os.Stderr, "invalid dst path: %v\n", err) + os.Exit(1) + } + if samePath(repoRoot, dst) { + fmt.Fprintln(os.Stderr, "invalid dst path: destination cannot be repo root") + os.Exit(1) + } + + if err := os.RemoveAll(dst); err != nil { + fmt.Fprintf(os.Stderr, "remove %s: %v\n", dst, err) + os.Exit(1) + } + + if err := copyTree(src, dst); err != nil { + fmt.Fprintf(os.Stderr, "copy %s -> %s: %v\n", src, dst, err) + os.Exit(1) + } +} + +func findRepoRoot() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("unable to locate copydir.go source path") + } + + scriptDir := filepath.Dir(file) + candidate := filepath.Clean(filepath.Join(scriptDir, "..")) + if err := validateRepoRoot(candidate); err == nil { + return candidate, nil + } + + wd, err := os.Getwd() + if err != nil { + return "", err + } + + cur, err := filepath.Abs(wd) + if err != nil { + return "", err + } + + for { + if err := validateRepoRoot(cur); err == nil { + return filepath.Clean(cur), nil + } + parent := filepath.Dir(cur) + if parent == cur { + return "", fmt.Errorf("could not find repository root from %s", wd) + } + cur = parent + } +} + +func validateRepoRoot(root string) error { + anchors := []string{ + filepath.Join(root, "go.sum"), + filepath.Join(root, "LICENSE"), + filepath.Join(root, ".github"), + } + for _, anchor := range anchors { + if _, err := os.Stat(anchor); err != nil { + return fmt.Errorf("missing repo anchor %s: %w", anchor, err) + } + } + return nil +} + +func normalizePathArg(arg, repoRoot string) (string, error) { + resolved := strings.ReplaceAll(arg, "${codespace}", repoRoot) + abs, err := filepath.Abs(resolved) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func ensurePathWithinRepo(repoRoot, path string) error { + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("path %s is outside repository root %s", path, repoRoot) + } + return nil +} + +func samePath(a, b string) bool { + return filepath.Clean(a) == filepath.Clean(b) +} + +func copyTree(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("source is not a directory: %s", src) + } + + return filepath.Walk(src, func(path string, entry os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + + target := dst + if rel != "." { + target = filepath.Join(dst, rel) + } + + if entry.IsDir() { + return os.MkdirAll(target, entry.Mode()) + } + + return copyFile(path, target, entry.Mode()) + }) +} + +func copyFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + + return out.Close() +} diff --git a/web/Makefile b/web/Makefile index 4dca810e7..254c439e9 100644 --- a/web/Makefile +++ b/web/Makefile @@ -2,15 +2,24 @@ build-android-arm64 build-android-bundle # Go variables -GO?=CGO_ENABLED=0 go +GO?=go WEB_GO?=$(GO) +CGO_ENABLED?=0 GO_BUILD_TAGS?=goolm,stdjson GOFLAGS?=-v -tags $(GO_BUILD_TAGS) +GOCACHE?=$(abspath ../.cache/go-build) +GOMODCACHE?=$(abspath ../.cache/go-mod) +GOTOOLCHAIN?=local +export CGO_ENABLED +export GOCACHE +export GOMODCACHE +export GOTOOLCHAIN # Build variables BUILD_DIR=build -OUTPUT?=$(BUILD_DIR)/picoclaw-launcher -OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64 +EXT= +OUTPUT?=$(BUILD_DIR)/picoclaw-launcher$(EXT) +OUTPUT_ANDROID_ARM64?=$(BUILD_DIR)/picoclaw-launcher-android-arm64$(EXT) FRONTEND_DIR=frontend FRONTEND_INSTALL_STAMP=$(FRONTEND_DIR)/node_modules/.picoclaw-install-stamp BACKEND_DIR=backend @@ -19,18 +28,47 @@ PICOCLAW_BINARY_NAME=picoclaw PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME)) LAUNCHER_GUI_LDFLAG= +ifeq ($(OS),Windows_NT) + POWERSHELL=powershell -NoProfile -Command + WINDOWS_GOARCH_RAW:=$(strip $(shell go env GOARCH 2>NUL)) +endif + # Version -VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") -BUILD_TIME=$(shell date +%FT%T%z) -GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}') +ifeq ($(OS),Windows_NT) + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>NUL)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>NUL)) + BUILD_TIME_RAW:=$(strip $(shell powershell -NoProfile -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK'")) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>NUL)) +else + VERSION_RAW:=$(strip $(shell git describe --tags --always --dirty 2>/dev/null)) + GIT_COMMIT_RAW:=$(strip $(shell git rev-parse --short=8 HEAD 2>/dev/null)) + BUILD_TIME_RAW:=$(strip $(shell date +%FT%T%z)) + GO_VERSION_RAW:=$(strip $(shell go env GOVERSION 2>/dev/null)) +endif +VERSION?=$(if $(VERSION_RAW),$(VERSION_RAW),dev) +GIT_COMMIT=$(if $(GIT_COMMIT_RAW),$(GIT_COMMIT_RAW),dev) +BUILD_TIME=$(if $(BUILD_TIME_RAW),$(BUILD_TIME_RAW),dev) +GO_VERSION=$(if $(GO_VERSION_RAW),$(GO_VERSION_RAW),unknown) CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w # OS detection -UNAME_S:=$(shell uname -s) -UNAME_M:=$(shell uname -m) +ifeq ($(OS),Windows_NT) + UNAME_S=Windows + ifeq ($(WINDOWS_GOARCH_RAW),amd64) + UNAME_M=x86_64 + else ifeq ($(WINDOWS_GOARCH_RAW),arm64) + UNAME_M=arm64 + else ifeq ($(WINDOWS_GOARCH_RAW),386) + UNAME_M=x86 + else + UNAME_M=$(if $(WINDOWS_GOARCH_RAW),$(WINDOWS_GOARCH_RAW),x86_64) + endif +else + UNAME_S:=$(shell uname -s) + UNAME_M:=$(shell uname -m) +endif # Platform-specific settings ifeq ($(UNAME_S),Linux) @@ -62,7 +100,14 @@ else ifeq ($(UNAME_S),Darwin) endif else ifeq ($(UNAME_S),Windows) PLATFORM=windows - ARCH=$(UNAME_M) + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH=arm64 + else + ARCH=$(UNAME_M) + endif + EXT=.exe PICOCLAW_BINARY_NAME=picoclaw.exe LAUNCHER_GUI_LDFLAG=-H=windowsgui else @@ -91,21 +136,36 @@ dev-backend: # Build frontend and embed into Go binary build: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(OUTPUT)') | Out-Null" +else @mkdir -p "$$(dirname "$(OUTPUT)")" +endif ${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/ # Build launcher for Android ARM64 (frontend must already be built) build-android-arm64: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" +else @mkdir -p $(BUILD_DIR) +endif GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(OUTPUT_ANDROID_ARM64)" ./$(BACKEND_DIR)/ # Build launcher for all Android architectures build-android-bundle: build-frontend +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path '$(BUILD_DIR)' | Out-Null" +else @mkdir -p $(BUILD_DIR) +endif GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o "$(BUILD_DIR)/picoclaw-launcher-android-arm64" ./$(BACKEND_DIR)/ @echo "All Android launcher builds complete" build-frontend: +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "if ((-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_DIR)/node_modules/.bin/tsc')) -or (-not (Test-Path -LiteralPath '$(FRONTEND_INSTALL_STAMP)')) -or ((Get-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Raw).Trim() -ne (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)))) { Write-Host 'Installing frontend dependencies...'; Push-Location '$(FRONTEND_DIR)'; try { pnpm install --frozen-lockfile } finally { Pop-Location }; Set-Content -LiteralPath '$(FRONTEND_INSTALL_STAMP)' -Value (((Get-FileHash -LiteralPath '$(FRONTEND_DIR)/package.json' -Algorithm SHA256).Hash + ':' + (Get-FileHash -LiteralPath '$(FRONTEND_DIR)/pnpm-lock.yaml' -Algorithm SHA256).Hash)) -NoNewline }" +else @expected_stamp="$$(cat $(FRONTEND_DIR)/package.json $(FRONTEND_DIR)/pnpm-lock.yaml | cksum | awk '{print $$1 ":" $$2}')"; \ if [ ! -d $(FRONTEND_DIR)/node_modules ] || \ [ ! -x $(FRONTEND_DIR)/node_modules/.bin/tsc ] || \ @@ -115,12 +175,17 @@ build-frontend: (cd $(FRONTEND_DIR) && CI=true pnpm install --frozen-lockfile) && \ printf '%s\n' "$$expected_stamp" > $(FRONTEND_INSTALL_STAMP); \ fi +endif @echo "Building frontend..." @cd $(FRONTEND_DIR) && pnpm build:backend build-dev-picoclaw: @echo "Building picoclaw for launcher development..." +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "New-Item -ItemType Directory -Force -Path (Split-Path -Parent '$(PICOCLAW_BINARY)') | Out-Null" +else @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")" +endif @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw # Run all tests @@ -135,5 +200,10 @@ lint: # Clean build artifacts clean: +ifeq ($(OS),Windows_NT) + @$(POWERSHELL) "$$paths=@('$(FRONTEND_DIR)/dist','$(BACKEND_DIST)','$(BUILD_DIR)'); foreach($$p in $$paths){ if (Test-Path -LiteralPath $$p) { Remove-Item -LiteralPath $$p -Recurse -Force } }" + @node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs +else rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR) node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs +endif diff --git a/web/backend/api/exec_nonwindows.go b/web/backend/api/exec_nonwindows.go new file mode 100644 index 000000000..0dc3c0e94 --- /dev/null +++ b/web/backend/api/exec_nonwindows.go @@ -0,0 +1,11 @@ +//go:build !windows + +package api + +import "os/exec" + +func launcherExecCommand(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) +} + +func applyLauncherProcAttrs(_ *exec.Cmd) {} diff --git a/web/backend/api/exec_windows.go b/web/backend/api/exec_windows.go new file mode 100644 index 000000000..86d3193a0 --- /dev/null +++ b/web/backend/api/exec_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package api + +import ( + "os/exec" + "syscall" +) + +func launcherExecCommand(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + applyLauncherProcAttrs(cmd) + return cmd +} + +func applyLauncherProcAttrs(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 201000ff3..67b055236 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -2,6 +2,7 @@ package api import ( "bufio" + "bytes" "encoding/json" "errors" "fmt" @@ -10,7 +11,9 @@ import ( "net/http" "os" "os/exec" + "reflect" "runtime" + "sort" "strconv" "strings" "sync" @@ -164,7 +167,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { `$p=Get-CimInstance Win32_Process -Filter "ProcessId = %d"; if ($null -eq $p) { "" } else { $p.CommandLine }`, pid, ) - out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output() + out, err := launcherExecCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", psCmd).Output() if err == nil { cmdline := strings.TrimSpace(string(out)) if cmdline != "" { @@ -173,7 +176,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { } // Fallback: determine only whether the process still exists. - out, err = exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output() + out, err = launcherExecCommand("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output() if err != nil { return false, false } @@ -187,7 +190,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { if strings.Contains(line, "\"picoclaw.exe\"") { return true, true } - return false, false + return false, true } if strings.Contains(line, "no tasks are running") { return false, true @@ -195,7 +198,7 @@ func isLikelyGatewayProcess(pid int) (bool, bool) { return false, true } - out, err := exec.Command("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() + out, err := launcherExecCommand("ps", "-o", "command=", "-p", strconv.Itoa(pid)).Output() if err != nil { return false, false } @@ -431,6 +434,10 @@ func computeConfigSignature(cfg *config.Config) string { } if cfg.Tools.Web.Enabled { toolSignatures = append(toolSignatures, "web") + webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web))) + if err == nil { + parts = append(parts, "webcfg:"+string(webConfig)) + } } if cfg.Tools.WebFetch.Enabled { toolSignatures = append(toolSignatures, "web_fetch") @@ -474,9 +481,175 @@ func computeConfigSignature(cfg *config.Config) string { if len(toolSignatures) > 0 { parts = append(parts, "tools:"+strings.Join(toolSignatures, ",")) } + channelSignatures := computeChannelSignatures(cfg.Channels) + if len(channelSignatures) > 0 { + parts = append(parts, "channels:"+strings.Join(channelSignatures, ",")) + } return strings.Join(parts, ";") } +func computeChannelSignatures(channels config.ChannelsConfig) []string { + if len(channels) == 0 { + return nil + } + + keys := make([]string, 0, len(channels)) + for name := range channels { + keys = append(keys, name) + } + sort.Strings(keys) + + signatures := make([]string, 0, len(keys)) + for _, name := range keys { + channel := channels[name] + if channel == nil { + signatures = append(signatures, name+":") + continue + } + + payload := struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id,omitempty"` + GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing config.TypingConfig `json:"typing,omitempty"` + Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` + }{ + Enabled: channel.Enabled, + Type: channel.Type, + AllowFrom: channel.AllowFrom, + ReasoningChannelID: channel.ReasoningChannelID, + GroupTrigger: channel.GroupTrigger, + Typing: channel.Typing, + Placeholder: channel.Placeholder, + Settings: normalizeChannelSettings(channel), + } + + encoded, err := json.Marshal(payload) + if err != nil { + signatures = append(signatures, name+":") + continue + } + signatures = append(signatures, name+":"+string(encoded)) + } + + return signatures +} + +func normalizeChannelSettings(channel *config.Channel) json.RawMessage { + if channel == nil { + return nil + } + + decoded, err := channel.GetDecoded() + if err == nil && decoded != nil { + normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded))) + if err == nil { + return normalized + } + } + + return normalizeRawJSON(channel.Settings) +} + +func normalizeRawJSON(raw config.RawNode) json.RawMessage { + if len(raw) == 0 { + return nil + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return bytes.TrimSpace(raw) + } + + normalized, err := json.Marshal(value) + if err != nil { + return bytes.TrimSpace(raw) + } + return normalized +} + +func canonicalizeSignatureValue(value reflect.Value) any { + if !value.IsValid() { + return nil + } + + if value.CanInterface() { + switch typed := value.Interface().(type) { + case config.SecureString: + return typed.String() + case *config.SecureString: + if typed == nil { + return "" + } + return typed.String() + case config.SecureStrings: + return typed.Values() + case *config.SecureStrings: + if typed == nil { + return nil + } + return typed.Values() + } + } + + switch value.Kind() { + case reflect.Interface, reflect.Pointer: + if value.IsNil() { + return nil + } + return canonicalizeSignatureValue(value.Elem()) + case reflect.Struct: + result := make(map[string]any) + valueType := value.Type() + for i := 0; i < value.NumField(); i++ { + field := valueType.Field(i) + if field.PkgPath != "" { + continue + } + tag := field.Tag.Get("json") + name := field.Name + if tag != "" { + if comma := strings.Index(tag, ","); comma >= 0 { + tag = tag[:comma] + } + if tag == "-" { + continue + } + if tag != "" { + name = tag + } + } + result[name] = canonicalizeSignatureValue(value.Field(i)) + } + return result + case reflect.Slice, reflect.Array: + length := value.Len() + result := make([]any, 0, length) + for i := 0; i < length; i++ { + result = append(result, canonicalizeSignatureValue(value.Index(i))) + } + return result + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return value.Interface() + } + result := make(map[string]any, value.Len()) + iter := value.MapRange() + for iter.Next() { + result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value()) + } + return result + default: + if value.CanInterface() { + return value.Interface() + } + return nil + } +} + func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool { if gatewayStatus != "running" { return false @@ -706,6 +879,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...) + applyLauncherProcAttrs(cmd) cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same @@ -741,6 +915,11 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int // Already holding gateway.mu from caller. if changed { refreshPicoTokensLocked(h.configPath) + cfg, err = config.LoadConfig(h.configPath) + if err != nil { + return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err) + } + defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) } if err := cmd.Start(); err != nil { diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 998ed3317..1d9352972 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -286,6 +286,61 @@ func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) } } +func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sleep command differs on Windows") + } + + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + delete(cfg.Channels, "pico") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd { + return exec.Command("sleep", "30") + } + + originalSignature := computeConfigSignature(cfg) + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + t.Fatalf("startGatewayLocked() error = %v", err) + } + if pid <= 0 { + t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid) + } + + gateway.mu.Lock() + cmd := gateway.cmd + bootSignature := gateway.bootConfigSignature + gateway.mu.Unlock() + t.Cleanup(func() { + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + if cmd != nil { + _ = cmd.Wait() + } + }) + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + expectedSignature := computeConfigSignature(updatedCfg) + if expectedSignature == originalSignature { + t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start") + } + if bootSignature != expectedSignature { + t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature) + } +} + func TestGatewayStartReady_NoDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -1108,6 +1163,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) { } } +func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegram := updatedCfg.Channels.Get("telegram") + if telegram == nil { + t.Fatalf("expected default telegram channel config") + } + telegram.Enabled = !telegram.Enabled + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + +func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + cfg.Tools.Web.Enabled = true + cfg.Tools.Web.Provider = "sogou" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Tools.Web.Provider = "duckduckgo" + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) { resetGatewayTestState(t) diff --git a/web/backend/api/router.go b/web/backend/api/router.go index f4ac78ab4..76f63607e 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -89,7 +89,6 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Skills and tools support/actions h.registerSkillRoutes(mux) h.registerToolRoutes(mux) - h.registerUIRoutes(mux) // OS startup / launch-at-login h.registerStartupRoutes(mux) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 6ac1eb988..cc18ee6e1 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/messageutil" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -48,8 +49,10 @@ 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"` + ToolCalls []utils.VisibleToolCall `json:"tool_calls,omitempty"` } type sessionChatAttachment struct { @@ -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 { @@ -450,7 +456,10 @@ func truncateRunes(s string, maxLen int) string { } func sessionChatMessageVisible(msg sessionChatMessage) bool { - return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0 + return strings.TrimSpace(msg.Content) != "" || + len(msg.Media) > 0 || + len(msg.Attachments) > 0 || + len(msg.ToolCalls) > 0 } func sessionChatMessagePreview(msg sessionChatMessage) string { @@ -469,10 +478,25 @@ func sessionChatMessagePreview(msg sessionChatMessage) string { } return "[attachment]" } + if len(msg.ToolCalls) > 0 { + return "[tool call]" + } return "" } func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage { + return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, false) +} + +func detailSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLength int) []sessionChatMessage { + return sessionTranscriptMessages(messages, toolFeedbackMaxArgsLength, true) +} + +func sessionTranscriptMessages( + messages []providers.Message, + toolFeedbackMaxArgsLength int, + includeThoughts bool, +) []sessionChatMessage { transcript := make([]sessionChatMessage, 0, len(messages)) for _, msg := range messages { @@ -494,31 +518,20 @@ 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 } - - toolSummaryMessages := visibleAssistantToolSummaryMessages(msg.ToolCalls, toolFeedbackMaxArgsLength) - if len(toolSummaryMessages) > 0 { - transcript = append(transcript, toolSummaryMessages...) + if includeThoughts { + if thoughtMsg, ok := assistantThoughtMessage(msg); ok { + transcript = append(transcript, thoughtMsg) + } } + toolCallsMsg, hasToolCallsMsg := assistantToolCallsMessage( + msg.ToolCalls, + toolFeedbackMaxArgsLength, + ) visibleToolMessages := visibleAssistantToolMessages(msg.ToolCalls) - if len(visibleToolMessages) > 0 { - transcript = append(transcript, visibleToolMessages...) - } - - // When assistant content exactly matches the rendered tool summary or - // tool-delivered message, skip it to avoid duplicates. Distinct content - // must remain visible in restored session history. - if len(msg.ToolCalls) > 0 && - len(msg.Media) == 0 && - len(attachments) == 0 && - assistantToolCallContentDuplicated(msg.Content, toolSummaryMessages, visibleToolMessages) { - continue - } // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed @@ -526,10 +539,19 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen content := msg.Content if assistantMessageInternalOnly(msg) { if len(attachments) == 0 { + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } continue } content = "" } + if hasToolCallsMsg && utils.ToolCallExplanationDuplicatesContent(content, msg.ToolCalls) { + content = "" + } chatMsg := sessionChatMessage{ Role: "assistant", @@ -538,10 +560,22 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen Attachments: attachments, } if !sessionChatMessageVisible(chatMsg) { + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } continue } transcript = append(transcript, chatMsg) + if hasToolCallsMsg { + transcript = append(transcript, toolCallsMsg) + } + if len(visibleToolMessages) > 0 { + transcript = append(transcript, visibleToolMessages...) + } } } @@ -559,43 +593,6 @@ func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessa return filtered } -func assistantToolCallContentDuplicated( - content string, - toolSummaryMessages []sessionChatMessage, - visibleToolMessages []sessionChatMessage, -) bool { - content = strings.TrimSpace(content) - if content == "" { - return false - } - - for _, msg := range toolSummaryMessages { - if toolSummaryContainsContent(msg.Content, content) { - return true - } - } - for _, msg := range visibleToolMessages { - if strings.TrimSpace(msg.Content) == content { - return true - } - } - return false -} - -func toolSummaryContainsContent(summary, content string) bool { - summary = strings.TrimSpace(summary) - content = strings.TrimSpace(content) - if summary == "" || content == "" { - return false - } - if summary == content { - return true - } - - _, body, hasBody := strings.Cut(summary, "\n") - return hasBody && strings.TrimSpace(body) == content -} - func sessionAttachments(msg providers.Message) []sessionChatAttachment { if len(msg.Attachments) == 0 { return nil @@ -672,77 +669,53 @@ 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 visibleAssistantToolSummaryMessages( +func assistantThoughtMessage(msg providers.Message) (sessionChatMessage, bool) { + reasoning := strings.TrimSpace(msg.ReasoningContent) + if reasoning == "" { + return sessionChatMessage{}, false + } + if reasoning == strings.TrimSpace(msg.Content) { + return sessionChatMessage{}, false + } + return sessionChatMessage{ + Role: "assistant", + Content: reasoning, + Kind: "thought", + }, true +} + +func assistantToolCallsMessage( toolCalls []providers.ToolCall, toolFeedbackMaxArgsLength int, -) []sessionChatMessage { +) (sessionChatMessage, bool) { if len(toolCalls) == 0 { - return nil + return sessionChatMessage{}, false } if toolFeedbackMaxArgsLength <= 0 { toolFeedbackMaxArgsLength = defaultToolFeedbackMaxArgsLength() } - messages := make([]sessionChatMessage, 0, len(toolCalls)) - for _, tc := range toolCalls { - name, argsJSON := toolCallNameAndArguments(tc) - if strings.TrimSpace(name) == "" { - continue - } - if name == "web_search" || name == "web_fetch" { - continue - } - if name == "message" { - if _, ok := parseMessageToolContent(argsJSON); ok { - continue - } - } - - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: utils.FormatToolFeedbackMessage( - name, - visibleAssistantToolSummaryText(tc, toolFeedbackMaxArgsLength), - ), - }) + visibleToolCalls := utils.BuildVisibleToolCalls(toolCalls, toolFeedbackMaxArgsLength) + if len(visibleToolCalls) == 0 { + return sessionChatMessage{}, false } - return messages + return sessionChatMessage{ + Role: "assistant", + Kind: "tool_calls", + ToolCalls: visibleToolCalls, + }, true } -func visibleAssistantToolSummaryText( +func visibleAssistantToolArgsPreview( tc providers.ToolCall, toolFeedbackMaxArgsLength int, ) string { - if tc.ExtraContent != nil { - if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { - return utils.Truncate(explanation, toolFeedbackMaxArgsLength) - } - } - - 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 { - argsJSON = string(encodedArgs) - } - } - - return utils.Truncate(strings.TrimSpace(argsJSON), toolFeedbackMaxArgsLength) + return utils.VisibleToolCallArgumentsPreview(tc, toolFeedbackMaxArgsLength) } func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { @@ -752,7 +725,7 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name, argsJSON := toolCallNameAndArguments(tc) + name, argsJSON := utils.VisibleToolCallNameAndArguments(tc) if name != "message" { continue } @@ -769,23 +742,6 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM return messages } -func toolCallNameAndArguments(tc providers.ToolCall) (string, string) { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments - } - if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { - if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encodedArgs) - } - } - return name, argsJSON -} - func parseMessageToolContent(argsJSON string) (string, bool) { var args struct { Content string `json:"content"` @@ -962,7 +918,7 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { } } - messages := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) + messages := detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index 6afb8a94f..760935db7 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/memory" @@ -31,6 +32,25 @@ func sessionsTestDir(t *testing.T, configPath string) string { return dir } +func assertVisibleToolCallMessage( + t *testing.T, + msg sessionChatMessage, + toolName string, +) utils.VisibleToolCall { + t.Helper() + + if msg.Role != "assistant" || msg.Kind != "tool_calls" { + t.Fatalf("message = %#v, want assistant/tool_calls", msg) + } + if len(msg.ToolCalls) != 1 { + t.Fatalf("len(message.ToolCalls) = %d, want 1", len(msg.ToolCalls)) + } + if got := msg.ToolCalls[0].Function; got == nil || got.Name != toolName { + t.Fatalf("tool call = %#v, want function %q", msg.ToolCalls[0], toolName) + } + return msg.ToolCalls[0] +} + func TestHandleListSessions_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -101,6 +121,64 @@ func TestHandleListSessions_JSONLStorage(t *testing.T) { } } +func TestHandleListSessions_TransientThoughtDoesNotInflateMessageCount(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + sessionKey := legacyPicoSessionPrefix + "history-jsonl-transient" + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + now := time.Now().UTC() + + rawJSONL := strings.Join([]string{ + `{"role":"user","content":"keep me"}`, + `{"role":"assistant","content":"","reasoning_content":"dangling thought"}`, + `{"role":"assistant","content":"and me"}`, + }, "\n") + "\n" + if err := os.WriteFile(base+".jsonl", []byte(rawJSONL), 0o644); err != nil { + t.Fatalf("WriteFile(jsonl) error = %v", err) + } + metaData, err := json.Marshal(memory.SessionMeta{ + Key: sessionKey, + Count: 3, + Skip: 0, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatalf("Marshal(meta) error = %v", err) + } + if err := os.WriteFile(base+".meta.json", metaData, 0o644); err != nil { + t.Fatalf("WriteFile(meta) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "history-jsonl-transient" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl-transient") + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2 after dropping transient thought", items[0].MessageCount) + } +} + func TestHandleListSessions_TitleUsesFirstUserMessage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -423,7 +501,7 @@ func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) { } } -func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { +func TestHandleGetSession_SkipsTransientThoughtMessages(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -457,10 +535,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -476,6 +551,166 @@ 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 []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if resp.Messages[1].Role != "assistant" || + resp.Messages[1].Content != "internal chain of thought" || + resp.Messages[1].Kind != "thought" { + t.Fatalf("thought message = %#v, want assistant thought/internal chain of thought", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final visible answer" { + t.Fatalf("final message = %#v, want assistant/final visible answer", resp.Messages[2]) + } +} + +func TestHandleGetSession_ReconstructsRefreshMatrixForThoughtAndToolSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-refresh-matrix" + for _, msg := range []providers.Message{ + {Role: "user", Content: "turn1"}, + {Role: "assistant", Content: "plain visible", ReasoningContent: "plain thought"}, + {Role: "user", Content: "turn2"}, + { + Role: "assistant", + ReasoningContent: "tool thought", + ToolCalls: []providers.ToolCall{{ + ID: "call_read_file", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_read_file", Content: "file result"}, + {Role: "user", Content: "turn3"}, + { + Role: "assistant", + Content: "tool visible only", + ToolCalls: []providers.ToolCall{{ + ID: "call_list_dir", + Type: "function", + Function: &providers.FunctionCall{ + Name: "list_dir", + Arguments: `{"path":"."}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_list_dir", Content: "dir result"}, + {Role: "user", Content: "turn4"}, + { + Role: "assistant", + Content: "tool visible and thought", + ReasoningContent: "tool mixed thought", + ToolCalls: []providers.ToolCall{{ + ID: "call_exec", + Type: "function", + Function: &providers.FunctionCall{ + Name: "exec", + Arguments: `{"command":"pwd"}`, + }, + }}, + }, + {Role: "tool", ToolCallID: "call_exec", Content: "pwd result"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-refresh-matrix", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 13 { + t.Fatalf("len(resp.Messages) = %d, want 13", len(resp.Messages)) + } + + assertMessage := func(index int, role, kind, content string) { + t.Helper() + msg := resp.Messages[index] + if msg.Role != role || msg.Kind != kind || msg.Content != content { + t.Fatalf("messages[%d] = %#v, want role=%q kind=%q content=%q", index, msg, role, kind, content) + } + } + + assertMessage(0, "user", "", "turn1") + assertMessage(1, "assistant", "thought", "plain thought") + assertMessage(2, "assistant", "", "plain visible") + assertMessage(3, "user", "", "turn2") + assertMessage(4, "assistant", "thought", "tool thought") + assertVisibleToolCallMessage(t, resp.Messages[5], "read_file") + assertMessage(6, "user", "", "turn3") + assertMessage(7, "assistant", "", "tool visible only") + assertVisibleToolCallMessage(t, resp.Messages[8], "list_dir") + assertMessage(9, "user", "", "turn4") + assertMessage(10, "assistant", "thought", "tool mixed thought") + assertMessage(11, "assistant", "", "tool visible and thought") + assertVisibleToolCallMessage(t, resp.Messages[12], "exec") +} + func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -524,27 +759,20 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSu } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 2 { - t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { - t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1]) - } - for _, msg := range resp.Messages { - if msg.Role == "tool" || strings.Contains(msg.Content, "`message`") { - t.Fatalf("unexpected raw tool or duplicate message-tool summary: %#v", msg) - } + assertVisibleToolCallMessage(t, resp.Messages[1], "message") + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { + t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) } } @@ -595,25 +823,23 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t * } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + if len(resp.Messages) != 4 { + t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) } if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { - t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1]) + assertVisibleToolCallMessage(t, resp.Messages[1], "message") + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { + t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" { - t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2]) + if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" { + t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3]) } } @@ -663,6 +889,67 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].MessageCount != 3 { + t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount) + } +} + +func TestHandleListSessions_DeduplicatesAssistantToolCallContentFromVisibleTranscript(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "list-deduped-tool-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "Read the file before replying.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var items []sessionListItem if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -725,10 +1012,7 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -739,11 +1023,10 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) } - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) - } - if !strings.Contains(resp.Messages[1].Content, "Read the file before replying.") { - t.Fatalf("tool summary message = %#v, want tool explanation", resp.Messages[1]) + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.ExtraContent == nil || + toolCall.ExtraContent.ToolFeedbackExplanation != "Read the file before replying." { + t.Fatalf("tool call = %#v, want explanation", toolCall) } } @@ -796,10 +1079,7 @@ func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -807,13 +1087,11 @@ func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T if len(resp.Messages) != 3 { t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) - } - if resp.Messages[2].Role != "assistant" || - resp.Messages[2].Content != "I will summarize the findings after reading the file." { - t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[2]) + if resp.Messages[1].Role != "assistant" || + resp.Messages[1].Content != "I will summarize the findings after reading the file." { + t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[1]) } + assertVisibleToolCallMessage(t, resp.Messages[2], "read_file") } func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { @@ -866,11 +1144,7 @@ func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSu } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - Media []string `json:"media"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -878,23 +1152,16 @@ func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSu if len(resp.Messages) != 3 { t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`view_image`") { - t.Fatalf("tool summary message = %#v, want view_image summary", resp.Messages[1]) + if resp.Messages[1].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role) } - if resp.Messages[2].Role != "assistant" { - t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role) + if resp.Messages[1].Content != "" { + t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content) } - if resp.Messages[2].Content != "Reviewing the generated screenshot." { - t.Fatalf("assistant content = %q, want preserved duplicated content with media", resp.Messages[2].Content) - } - if len(resp.Messages[2].Media) != 1 || resp.Messages[2].Media[0] != "data:image/png;base64,abc123" { - t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[2].Media) - } - for _, msg := range resp.Messages { - if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") { - t.Fatalf("unexpected raw tool result in history: %#v", msg) - } + if len(resp.Messages[1].Media) != 1 || resp.Messages[1].Media[0] != "data:image/png;base64,abc123" { + t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[1].Media) } + assertVisibleToolCallMessage(t, resp.Messages[2], "view_image") } func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { @@ -964,21 +1231,19 @@ func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplic if len(resp.Messages) != 3 { t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) + if resp.Messages[1].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[1].Role) } - if resp.Messages[2].Role != "assistant" { - t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role) + if resp.Messages[1].Content != "" { + t.Fatalf("assistant content = %q, want duplicate content suppressed", resp.Messages[1].Content) } - if resp.Messages[2].Content != "Reviewing the generated report." { - t.Fatalf("assistant content = %q, want preserved duplicated content", resp.Messages[2].Content) + if len(resp.Messages[1].Attachments) != 1 { + t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[1].Attachments)) } - if len(resp.Messages[2].Attachments) != 1 { - t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[2].Attachments)) - } - if resp.Messages[2].Attachments[0].URL != "https://example.com/report.txt" { - t.Fatalf("attachment url = %q, want report URL", resp.Messages[2].Attachments[0].URL) + if resp.Messages[1].Attachments[0].URL != "https://example.com/report.txt" { + t.Fatalf("attachment url = %q, want report URL", resp.Messages[1].Attachments[0].URL) } + assertVisibleToolCallMessage(t, resp.Messages[2], "read_file") } func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) { @@ -1039,10 +1304,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } err = json.Unmarshal(rec.Body.Bytes(), &resp) if err != nil { @@ -1052,15 +1314,15 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) } - wantPreview := utils.Truncate(explanation, 20) - if !strings.Contains(resp.Messages[1].Content, wantPreview) { - t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview) + wantArgsPreview := visibleAssistantToolArgsPreview(providers.ToolCall{ + Function: &providers.FunctionCall{Arguments: argsJSON}, + }, 20) + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.ExtraContent == nil || toolCall.ExtraContent.ToolFeedbackExplanation != explanation { + t.Fatalf("tool call = %#v, want full explanation %q", toolCall, explanation) } - if strings.Contains(resp.Messages[1].Content, argsJSON) { - t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content) - } - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) + if toolCall.Function == nil || toolCall.Function.Arguments != wantArgsPreview { + t.Fatalf("tool call = %#v, want args preview %q", toolCall, wantArgsPreview) } } @@ -1120,10 +1382,7 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t } var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` + Messages []sessionChatMessage `json:"messages"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) @@ -1132,12 +1391,12 @@ func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) } - wantPreview := utils.Truncate(argsJSON, 20) - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) - } - if !strings.Contains(resp.Messages[1].Content, wantPreview) { - t.Fatalf("tool summary = %q, want legacy args preview %q", resp.Messages[1].Content, wantPreview) + wantPreview := visibleAssistantToolArgsPreview(providers.ToolCall{ + Function: &providers.FunctionCall{Arguments: argsJSON}, + }, 20) + toolCall := assertVisibleToolCallMessage(t, resp.Messages[1], "read_file") + if toolCall.Function == nil || toolCall.Function.Arguments != wantPreview { + t.Fatalf("tool call = %#v, want legacy args preview %q", toolCall, wantPreview) } } diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index c6c2deaae..3476e3c53 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -171,6 +171,12 @@ var toolCatalog = []toolCatalogEntry{ Category: "hardware", ConfigKey: "spi", }, + { + Name: "serial", + Description: "Interact with serial ports exposed on the host.", + Category: "hardware", + ConfigKey: "serial", + }, { Name: "tool_search_tool_regex", Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.", @@ -265,6 +271,8 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem { status, reasonCode = resolveWebSearchToolSupport(cfg) case "i2c", "spi": status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) + case "serial": + status, reasonCode = resolveSerialToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) default: if cfg.Tools.IsToolEnabled(entry.ConfigKey) { status = "enabled" @@ -293,6 +301,18 @@ func resolveHardwareToolSupport(enabled bool) (string, string) { return "enabled", "" } +func resolveSerialToolSupport(enabled bool) (string, string) { + if !enabled { + return "disabled", "" + } + switch runtime.GOOS { + case "linux", "darwin", "windows": + return "enabled", "" + default: + return "blocked", "requires_serial_platform" + } +} + func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) { if !cfg.Tools.IsToolEnabled("mcp") { return "disabled", "" @@ -362,6 +382,8 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error { cfg.Tools.I2C.Enabled = enabled case "spi": cfg.Tools.SPI.Enabled = enabled + case "serial": + cfg.Tools.Serial.Enabled = enabled case "tool_search_tool_regex": cfg.Tools.MCP.Discovery.UseRegex = enabled if enabled { diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go index ffeae9b64..a09a49fd6 100644 --- a/web/backend/api/tools_test.go +++ b/web/backend/api/tools_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/sipeed/picoclaw/pkg/config" - picotools "github.com/sipeed/picoclaw/pkg/tools" ) func TestHandleListTools(t *testing.T) { @@ -93,9 +92,36 @@ func TestHandleListTools(t *testing.T) { if gotTools["i2c"].Status != "disabled" { t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status) } + if gotTools["serial"].Status != "disabled" { + t.Fatalf("serial status = %q, want disabled when config is off", gotTools["serial"].Status) + } + + cfg.Tools.Serial.Enabled = true + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools = make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + if gotTools["serial"].Status != "enabled" { + t.Fatalf("serial = %#v, want enabled on linux when config is on", gotTools["serial"]) + } } else { cfg.Tools.I2C.Enabled = true cfg.Tools.SPI.Enabled = true + cfg.Tools.Serial.Enabled = true if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -121,6 +147,16 @@ func TestHandleListTools(t *testing.T) { if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" { t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"]) } + switch runtime.GOOS { + case "darwin", "windows": + if gotTools["serial"].Status != "enabled" { + t.Fatalf("serial = %#v, want enabled on supported host", gotTools["serial"]) + } + default: + if gotTools["serial"].Status != "blocked" || gotTools["serial"].ReasonCode != "requires_serial_platform" { + t.Fatalf("serial = %#v, want blocked/requires_serial_platform", gotTools["serial"]) + } + } } } @@ -196,6 +232,26 @@ func TestHandleUpdateToolState(t *testing.T) { if !updated.Tools.Cron.Enabled { t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron) } + + rec4 := httptest.NewRecorder() + req4 := httptest.NewRequest( + http.MethodPut, + "/api/tools/serial/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req4.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec4, req4) + if rec4.Code != http.StatusOK { + t.Fatalf("serial status = %d, want %d, body=%s", rec4.Code, http.StatusOK, rec4.Body.String()) + } + + updated, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig(updated serial) error = %v", err) + } + if !updated.Tools.Serial.Enabled { + t.Fatalf("serial should be enabled: %#v", updated.Tools.Serial) + } } func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) { @@ -517,22 +573,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) } diff --git a/web/backend/api/ui.go b/web/backend/api/ui.go deleted file mode 100644 index 90d96403e..000000000 --- a/web/backend/api/ui.go +++ /dev/null @@ -1,27 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/sipeed/picoclaw/pkg/tools" -) - -type uiLanguageRequest struct { - Language string `json:"language"` -} - -func (h *Handler) registerUIRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /api/ui/language", h.handleSetUILanguage) -} - -func (h *Handler) handleSetUILanguage(w http.ResponseWriter, r *http.Request) { - var req uiLanguageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - - tools.SetPreferredWebSearchLanguage(req.Language) - w.WriteHeader(http.StatusNoContent) -} diff --git a/web/backend/api/ui_test.go b/web/backend/api/ui_test.go deleted file mode 100644 index 3de35b7cb..000000000 --- a/web/backend/api/ui_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/sipeed/picoclaw/pkg/tools" -) - -func TestHandleSetUILanguage(t *testing.T) { - tools.SetPreferredWebSearchLanguage("") - t.Cleanup(func() { - tools.SetPreferredWebSearchLanguage("") - }) - - h := NewHandler("") - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{"language":"zh"}`)) - req.Header.Set("Content-Type", "application/json") - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - if got := tools.GetPreferredWebSearchLanguage(); got != "zh" { - t.Fatalf("preferred web search language = %q, want zh", got) - } -} - -func TestHandleSetUILanguage_RejectsInvalidJSON(t *testing.T) { - h := NewHandler("") - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/ui/language", strings.NewReader(`{`)) - req.Header.Set("Content-Type", "application/json") - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) - } -} diff --git a/web/backend/main.go b/web/backend/main.go index f5362174b..fa2448d5c 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -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() diff --git a/web/frontend/package.json b/web/frontend/package.json index 835682617..ab07b40a2 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -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" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 210c111c5..cb5ca18de 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -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 diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index 912fbecd8..edd7d7c27 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -14,6 +14,7 @@ export interface SessionDetail { messages: { role: "user" | "assistant" content: string + kind?: "normal" | "thought" | "tool_calls" media?: string[] attachments?: { type?: "image" | "audio" | "video" | "file" @@ -21,6 +22,17 @@ export interface SessionDetail { filename?: string content_type?: string }[] + tool_calls?: { + id?: string + type?: string + function?: { + name?: string + arguments?: string + } + extra_content?: { + tool_feedback_explanation?: string + } + }[] }[] summary: string created: string diff --git a/web/frontend/src/components/agent/tools/tools-page.tsx b/web/frontend/src/components/agent/tools/tools-page.tsx index 7d2d0fac6..c221f911c 100644 --- a/web/frontend/src/components/agent/tools/tools-page.tsx +++ b/web/frontend/src/components/agent/tools/tools-page.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef } from "react" import { useTranslation } from "react-i18next" + import { PageHeader } from "@/components/page-header" import { ToolLibraryTab } from "./tool-library-tab" @@ -26,6 +27,7 @@ export function ToolsPage() { isToolsLoading, isWebSearchLoading, isWebSearchSaving, + isWebSearchDirty, setActiveTab, setSearchQuery, setStatusFilter, @@ -72,6 +74,7 @@ export function ToolsPage() { isLoading={isWebSearchLoading} hasError={hasWebSearchError} isSaving={isWebSearchSaving} + isDirty={isWebSearchDirty} onSave={saveWebSearchConfig} onToggleProviderExpand={toggleExpandedProvider} onUpdateDraft={updateWebSearchDraft} diff --git a/web/frontend/src/components/agent/tools/use-tools-page.ts b/web/frontend/src/components/agent/tools/use-tools-page.ts index 07f9d50d4..ecc433b0e 100644 --- a/web/frontend/src/components/agent/tools/use-tools-page.ts +++ b/web/frontend/src/components/agent/tools/use-tools-page.ts @@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { + type WebSearchConfigResponse, getTools, getWebSearchConfig, setToolEnabled, updateWebSearchConfig, - type WebSearchConfigResponse, } from "@/api/tools" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types" @@ -35,24 +36,38 @@ export function useToolsPage() { queryFn: getWebSearchConfig, }) - const tools = useMemo(() => toolsQuery.data?.tools ?? [], [toolsQuery.data?.tools]) + const tools = useMemo( + () => toolsQuery.data?.tools ?? [], + [toolsQuery.data?.tools], + ) const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null + const isWebSearchDirty = useMemo(() => { + if (!webSearchDraft || !webSearchQuery.data) { + return false + } + return ( + JSON.stringify(webSearchDraft) !== JSON.stringify(webSearchQuery.data) + ) + }, [webSearchDraft, webSearchQuery.data]) const toggleToolMutation = useMutation({ mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => setToolEnabled(name, enabled), - onSuccess: (_, variables) => { - toast.success( + onSuccess: async (_, variables) => { + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, variables.enabled ? t("pages.agent.tools.enable_success", "Tool enabled successfully") : t( "pages.agent.tools.disable_success", "Tool disabled successfully", ), + t("navigation.tools", "Tools"), + gateway?.restartRequired === true, ) void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) }, onError: (error) => { toast.error( @@ -65,20 +80,23 @@ export function useToolsPage() { const saveWebSearchMutation = useMutation({ mutationFn: updateWebSearchConfig, - onSuccess: (updatedConfig) => { + onSuccess: async (updatedConfig) => { queryClient.setQueryData(["tools", "web-search-config"], updatedConfig) setWebSearchDraftOverride(null) - toast.success( + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, t( "pages.agent.tools.web_search.save_success", "Settings saved successfully", ), + t("pages.agent.tools.web_search.title", "Web Search Configuration"), + gateway?.restartRequired === true, ) void queryClient.invalidateQueries({ queryKey: ["tools", "web-search-config"], }) void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) }, onError: (error) => { toast.error( @@ -105,7 +123,9 @@ export function useToolsPage() { } if (normalizedSearchQuery) { - const matchesName = tool.name.toLowerCase().includes(normalizedSearchQuery) + const matchesName = tool.name + .toLowerCase() + .includes(normalizedSearchQuery) const matchesDescription = (tool.description || "") .toLowerCase() .includes(normalizedSearchQuery) @@ -177,6 +197,7 @@ export function useToolsPage() { isToolsLoading: toolsQuery.isLoading, isWebSearchLoading: webSearchQuery.isLoading, isWebSearchSaving: saveWebSearchMutation.isPending, + isWebSearchDirty, setActiveTab, setSearchQuery, setStatusFilter, diff --git a/web/frontend/src/components/agent/tools/web-search-tab.tsx b/web/frontend/src/components/agent/tools/web-search-tab.tsx index b3f9d0750..866e0f27f 100644 --- a/web/frontend/src/components/agent/tools/web-search-tab.tsx +++ b/web/frontend/src/components/agent/tools/web-search-tab.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next" import type { WebSearchConfigResponse } from "@/api/tools" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -15,6 +16,7 @@ interface WebSearchTabProps { isLoading: boolean hasError: boolean isSaving: boolean + isDirty: boolean onSave: () => void onToggleProviderExpand: (providerId: string) => void onUpdateDraft: WebSearchDraftUpdater @@ -27,6 +29,7 @@ export function WebSearchTab({ isLoading, hasError, isSaving, + isDirty, onSave, onToggleProviderExpand, onUpdateDraft, @@ -66,13 +69,21 @@ export function WebSearchTab({ + {isDirty && ( + + )} +
()) + const loadRequestIdRef = useRef(0) + + const resetPageState = useCallback(() => { + arrayFieldFlushersRef.current.clear() + setChannel(null) + setBaseConfig({}) + setEditConfig({}) + setConfiguredSecrets([]) + setEnabled(false) + setFetchError("") + setServerError("") + setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) + }, []) const loadData = useCallback( async (silent = false) => { + const requestId = loadRequestIdRef.current + 1 + loadRequestIdRef.current = requestId if (!silent) setLoading(true) try { const catalog = await getChannelsCatalog() + if (loadRequestIdRef.current !== requestId) return const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { - setChannel(null) - setBaseConfig({}) - setEditConfig({}) - setConfiguredSecrets([]) - setEnabled(false) + resetPageState() setFetchError( t("channels.page.notFound", { name: channelName, @@ -320,6 +335,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { } const channelConfig = await getChannelConfig(channelName) + if (loadRequestIdRef.current !== requestId) return const raw = asRecord(channelConfig.config) const normalized = normalizeConfig(matched, raw) @@ -332,18 +348,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) } catch (e) { + if (loadRequestIdRef.current !== requestId) return setConfiguredSecrets([]) setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { - if (!silent) setLoading(false) + if (!silent && loadRequestIdRef.current === requestId) { + setLoading(false) + } } }, - [channelName, t], + [channelName, resetPageState, t], ) useEffect(() => { + resetPageState() + setLoading(true) loadData() - }, [loadData]) + }, [loadData, resetPageState]) const previousGatewayStatusRef = useRef(gatewayState) useEffect(() => { @@ -359,6 +380,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return isConfigured(channel, editConfig, configuredSecrets) }, [channel, configuredSecrets, editConfig]) + const isDirty = useMemo(() => { + if (loading || !channel || channel.name !== channelName) return false + const basePayload = buildSavePayload( + channel, + buildEditConfig(channel.name, baseConfig), + asBool(baseConfig.enabled), + ) + const currentPayload = buildSavePayload(channel, editConfig, enabled) + return JSON.stringify(basePayload) !== JSON.stringify(currentPayload) + }, [baseConfig, channel, channelName, editConfig, enabled, loading]) + const docsUrl = useMemo(() => { if (!channel) return "" if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" @@ -479,6 +511,13 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }, }) await loadData() + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("channels.page.saveSuccess"), + channelDisplayName, + gateway?.restartRequired === true, + ) } catch (e) { const message = e instanceof Error ? e.message : t("channels.page.saveError") @@ -674,11 +713,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {

{serverError}

)} + {isDirty && ( + + )} +
- -
diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index c09f5a06d..07a3c0abc 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -5,8 +5,8 @@ import { IconCopy, IconDownload, IconFileText, + IconTool, } from "@tabler/icons-react" -import { useAtom } from "jotai" import { useState } from "react" import { useTranslation } from "react-i18next" import ReactMarkdown from "react-markdown" @@ -18,31 +18,41 @@ 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 AssistantMessageKind, + type ChatAttachment, + type ChatToolCall, +} from "@/store/chat" interface AssistantMessageProps { content: string attachments?: ChatAttachment[] - isThought?: boolean + kind?: AssistantMessageKind + toolCalls?: ChatToolCall[] timestamp?: string | number } export function AssistantMessage({ content, attachments = [], - isThought = false, + kind = "normal", + toolCalls = [], timestamp = "", }: AssistantMessageProps) { const { t } = useTranslation() const [isCopied, setIsCopied] = useState(false) + const isThought = kind === "thought" + const isToolCalls = kind === "tool_calls" + const isCollapsedBlock = isThought || isToolCalls const hasText = content.trim().length > 0 + const hasToolCalls = toolCalls.length > 0 const imageAttachments = attachments.filter( (attachment) => attachment.type === "image", ) const fileAttachments = attachments.filter( (attachment) => attachment.type !== "image", ) - const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom) + const [isExpanded, setIsExpanded] = useState(true) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" @@ -53,9 +63,13 @@ export function AssistantMessage({ }) } + const collapsedLabel = isThought + ? t("chat.reasoningLabel") + : t("chat.toolCallsLabel") + return (
- {!isThought && ( + {!isCollapsedBlock && (
PicoClaw @@ -69,23 +83,27 @@ export function AssistantMessage({
)} - {(hasText || isThought) && ( + {(hasText || isCollapsedBlock || hasToolCalls) && (
- {isThought && ( + {isCollapsedBlock && (
setIsExpanded(!isExpanded)} >
- - {t("chat.reasoningLabel")} + {isThought ? ( + + ) : ( + + )} + {collapsedLabel}
)} - {(!isThought || isExpanded) && hasText && ( + {(!isCollapsedBlock || isExpanded) && isToolCalls && hasToolCalls && ( +
+ {toolCalls.map((toolCall, index) => { + const explanation = + toolCall.extraContent?.toolFeedbackExplanation?.trim() ?? "" + const toolName = toolCall.function?.name?.trim() ?? "" + const toolArguments = toolCall.function?.arguments?.trim() ?? "" + const hasFunctionSummary = toolName || toolArguments + + if (!explanation && !hasFunctionSummary) { + return null + } + + return ( +
0 && "border-border/20 border-t pt-3", + )} + > + {explanation && ( +
+
+ {t("chat.toolCallExplanationLabel")} +
+
+ + {explanation} + +
+
+ )} + + {hasFunctionSummary && ( +
+
+ {t("chat.toolCallFunctionLabel")} +
+
+ {toolName && ( +
+ {toolName} +
+ )} + {toolArguments && ( +
+                              {toolArguments}
+                            
+ )} +
+
+ )} +
+ ) + })} +
+ )} + {(!isCollapsedBlock || isExpanded) && !isToolCalls && hasText && (
)} - {!isThought && hasText && ( + {!isCollapsedBlock && hasText && (
diff --git a/web/frontend/src/components/config-change-notice.tsx b/web/frontend/src/components/config-change-notice.tsx new file mode 100644 index 000000000..27e5eed7d --- /dev/null +++ b/web/frontend/src/components/config-change-notice.tsx @@ -0,0 +1,48 @@ +import { + IconAlertCircle, + IconDeviceFloppy, + IconRefresh, +} from "@tabler/icons-react" + +import { cn } from "@/lib/utils" + +interface ConfigChangeNoticeProps { + kind: "save" | "restart" + title: string + description?: string + className?: string +} + +export function ConfigChangeNotice({ + kind, + title, + description, + className, +}: ConfigChangeNoticeProps) { + const Icon = + kind === "restart" + ? IconRefresh + : kind === "save" + ? IconDeviceFloppy + : IconAlertCircle + + return ( +
+ +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index f50503dec..0b5665640 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -15,6 +15,7 @@ import { setAutoStartEnabled as updateAutoStartEnabled, setLauncherConfig as updateLauncherConfig, } from "@/api/system" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { AgentDefaultsSection, CronSection, @@ -36,6 +37,7 @@ import { import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" export function ConfigPage() { @@ -244,6 +246,7 @@ export function ConfigPage() { tool_feedback: { enabled: form.toolFeedbackEnabled, max_args_length: toolFeedbackMaxArgsLength, + separate_messages: form.toolFeedbackSeparateMessages, }, max_tokens: maxTokens, context_window: contextWindow, @@ -333,8 +336,13 @@ export function ConfigPage() { queryClient.setQueryData(["system", "autostart"], status) } - toast.success(t("pages.config.save_success")) - void refreshGatewayState({ force: true }) + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) } catch (err) { toast.error( err instanceof Error ? err.message : t("pages.config.save_error"), @@ -432,8 +440,12 @@ export function ConfigPage() { {isDirty && (
-
- {t("pages.config.unsaved_changes")} +
+
{actionButtons}
diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 25c335ab1..fa6b3a079 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -113,6 +113,18 @@ export function AgentDefaultsSection({ } /> + {form.toolFeedbackEnabled && ( + + onFieldChange("toolFeedbackSeparateMessages", checked) + } + /> + )} + {form.toolFeedbackEnabled && ( { - toast.success(t("pages.config.save_success")) try { const savedConfig = JSON.parse(submittedConfig) setLastSavedConfig(savedConfig) @@ -58,7 +59,14 @@ export function RawConfigPage() { } catch { queryClient.invalidateQueries({ queryKey: ["config"] }) } - void refreshGatewayState({ force: true }) + void refreshGatewayState({ force: true }).then((gateway) => { + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) + }) }, onError: () => { toast.error(t("pages.config.save_error")) @@ -141,9 +149,12 @@ export function RawConfigPage() { ) : (
{isDirty && ( -
- {t("pages.config.unsaved_changes")} -
+ )}