From 3b173c0beee1232472b1e0593b14d3988b47661d Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 13:58:19 +0200 Subject: [PATCH 01/71] feat(agent): add multi-agent discovery prompt and per-agent --- docs/configuration.md | 82 ++++++ docs/it/configuration.md | 87 +++++- pkg/agent/context.go | 71 ++++- pkg/agent/context_budget_test.go | 13 +- pkg/agent/context_cache_test.go | 27 +- pkg/agent/context_test.go | 41 ++- pkg/agent/definition_test.go | 18 +- pkg/agent/discovery.go | 341 +++++++++++++++++++++++ pkg/agent/discovery_test.go | 211 ++++++++++++++ pkg/agent/eventbus_test.go | 25 +- pkg/agent/hook_mount_test.go | 30 +- pkg/agent/hook_process.go | 17 +- pkg/agent/hooks.go | 20 +- pkg/agent/hooks_test.go | 5 +- pkg/agent/instance.go | 20 +- pkg/agent/instance_test.go | 6 +- pkg/agent/loop.go | 135 +++++++-- pkg/agent/loop_mcp.go | 20 +- pkg/agent/loop_media.go | 6 +- pkg/agent/loop_test.go | 121 ++++++-- pkg/agent/model_resolution.go | 8 +- pkg/agent/registry.go | 20 +- pkg/agent/registry_test.go | 75 ++++- pkg/agent/steering.go | 5 +- pkg/agent/steering_test.go | 30 +- pkg/agent/subturn.go | 25 +- pkg/agent/subturn_test.go | 10 +- pkg/agent/tool_allowlist.go | 30 ++ pkg/config/config.go | 120 ++++---- pkg/config/config_test.go | 94 +++++-- pkg/config/defaults.go | 6 +- pkg/config/migration.go | 3 +- pkg/config/migration_integration_test.go | 41 ++- pkg/config/migration_test.go | 57 +++- pkg/config/model_config_test.go | 24 +- pkg/config/multikey_test.go | 10 +- pkg/config/security.go | 6 +- pkg/config/security_integration_test.go | 29 +- pkg/tools/cron.go | 22 +- pkg/tools/cron_test.go | 42 ++- pkg/tools/edit.go | 17 +- pkg/tools/edit_test.go | 6 +- pkg/tools/filesystem.go | 16 +- pkg/tools/filesystem_test.go | 53 +++- pkg/tools/i2c.go | 8 +- pkg/tools/i2c_linux.go | 42 ++- pkg/tools/mcp_tool.go | 34 ++- pkg/tools/mcp_tool_test.go | 5 +- pkg/tools/message_test.go | 10 +- pkg/tools/normalization.go | 30 +- pkg/tools/registry.go | 63 ++++- pkg/tools/registry_test.go | 66 ++++- pkg/tools/result.go | 5 +- pkg/tools/result_test.go | 6 +- pkg/tools/search_tool.go | 52 +++- pkg/tools/search_tools_test.go | 5 +- pkg/tools/send_file.go | 5 +- pkg/tools/send_file_test.go | 12 +- pkg/tools/shell.go | 72 ++++- pkg/tools/shell_test.go | 95 +++++-- pkg/tools/skills_install.go | 15 +- pkg/tools/skills_search.go | 5 +- pkg/tools/spawn_status.go | 6 +- pkg/tools/spawn_status_test.go | 12 +- pkg/tools/spi.go | 8 +- pkg/tools/spi_linux.go | 43 ++- pkg/tools/subagent_tool_test.go | 6 +- pkg/tools/toolloop.go | 17 +- pkg/tools/validate_test.go | 26 +- pkg/tools/web_test.go | 162 ++++++++--- 70 files changed, 2453 insertions(+), 402 deletions(-) create mode 100644 pkg/agent/discovery.go create mode 100644 pkg/agent/discovery_test.go create mode 100644 pkg/agent/tool_allowlist.go diff --git a/docs/configuration.md b/docs/configuration.md index 3462767e6..9c201c787 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -246,6 +246,88 @@ In other words: **channel + account form the candidate set; peer/guild/team then - **Wildcard catches too much traffic?** Add more specific `peer/guild/team` rules for critical paths. - **Unexpected default fallback?** Confirm `agent_id` exists and is not misspelled. +### Agent Tool Allowlist + +You can restrict an individual agent to a subset of runtime tools with `agents.list[].tools`. + +If `tools` is omitted, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed tools for that agent. + +```json +{ + "agents": { + "list": [ + { + "id": "research", + "name": "Research Agent", + "tools": ["read_file", "write_file", "web_search", "web_fetch", "message"] + } + ] + } +} +``` + +Notes: + +- This is an allowlist, not a preference hint. +- Tool names are matched against the runtime tool name 1:1. +- Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. +- The `available_tools` field in Agent Discovery reflects the filtered runtime result. + +### Agent Discovery (Automatic) + +When more than one agent exists, PicoClaw injects a structured agent registry into each agent's system prompt on every turn. No extra `list_agents` tool call is required. + +This registry is intended to make delegation concrete and reliable, especially when using `spawn` with a target `agent_id`. + +Each entry includes: + +| Field | Meaning | +|-------|---------| +| `id` | Stable agent id | +| `name` | Human-friendly agent name | +| `description` | Short capability summary | +| `model` | Current model used by that agent | +| `available_tools` | Tool names currently visible to that agent | +| `channels` | Channels that route to that agent | + +Important behavior: + +- The discovery section includes the current agent's own entry, so the model has self-awareness. +- `available_tools` is the most important field for delegation. It reflects the tools the target agent can actually use, not just a natural-language description. +- `description` is sourced from `AGENT.md` frontmatter `description` when available, otherwise from the first meaningful paragraph of `AGENT.md`, and finally `SOUL.md`. +- `name` comes from `agents.list[].name` first, then `AGENT.md` frontmatter `name`, then falls back to the agent id. +- `channels` come from routing state: + - the default agent exposes enabled channels + - other agents expose channels that explicitly bind to them through `bindings` + +Example injected shape: + +```json +{ + "current_agent_id": "main", + "agents": [ + { + "id": "main", + "name": "Main Assistant", + "description": "Generalist agent for day-to-day requests.", + "model": "gpt-4o-mini", + "available_tools": ["read_file", "write_file", "exec", "spawn"], + "channels": ["telegram", "discord"] + }, + { + "id": "research", + "name": "Research Agent", + "description": "Specialist for long-form investigation and web work.", + "model": "claude-sonnet-4.5", + "available_tools": ["web_search", "web_fetch", "read_file"], + "channels": ["telegram"] + } + ] +} +``` + +In practice, this means a generalist agent can see that a peer has `["web_search", "web_fetch"]` while it only has local file tools, and can decide to delegate to that peer instead of guessing. + ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. diff --git a/docs/it/configuration.md b/docs/it/configuration.md index 6a79a9543..9b0d4a198 100644 --- a/docs/it/configuration.md +++ b/docs/it/configuration.md @@ -42,14 +42,13 @@ PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/works ├── state/ # Stato persistente (ultimo canale, ecc.) ├── cron/ # Database dei job pianificati ├── skills/ # Skill personalizzate -├── AGENTS.md # Guida al comportamento dell'agent +├── AGENT.md # Guida al comportamento dell'agent ├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min) -├── IDENTITY.md # Identità dell'agent ├── SOUL.md # Anima dell'agent └── USER.md # Preferenze dell'utente ``` -> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta. +> **Nota:** Le modifiche a `AGENT.md`, `SOUL.md`, `USER.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta. ### Sorgenti delle Skill @@ -72,6 +71,88 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills - Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente. - Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione. +### Allowlist dei Tool per Agent + +Puoi limitare un singolo agent a un sottoinsieme di tool runtime con `agents.list[].tools`. + +Se `tools` è omesso, l'agent riceve il normale set globale dei tool abilitati. Se `tools` è presente, PicoClaw registra per quell'agent solo i tool elencati. + +```json +{ + "agents": { + "list": [ + { + "id": "research", + "name": "Research Agent", + "tools": ["read_file", "write_file", "web_search", "web_fetch", "message"] + } + ] + } +} +``` + +Note: + +- È una allowlist reale, non un suggerimento per l'LLM. +- I nomi dei tool fanno match 1:1 con il nome runtime del tool. +- Se ti serve controllo preciso, usa i nomi runtime effettivi come `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. +- Il campo `available_tools` nella Agent Discovery riflette il risultato filtrato reale. + +### Discovery Multi-Agent (Automatica) + +Quando esiste più di un agent, PicoClaw inietta automaticamente nel system prompt di ogni agent un registry strutturato dei peer. Non serve una chiamata aggiuntiva a un tool `list_agents`. + +Questa discovery serve soprattutto a rendere affidabile la delega tramite `spawn` con `agent_id` esplicito. + +Ogni entry include: + +| Campo | Significato | +|-------|-------------| +| `id` | ID stabile dell'agent | +| `name` | Nome leggibile dell'agent | +| `description` | Riassunto breve delle capacità | +| `model` | Modello attualmente usato da quell'agent | +| `available_tools` | Tool attualmente visibili a quell'agent | +| `channels` | Canali instradati verso quell'agent | + +Dettagli importanti: + +- La sezione include anche l'entry dell'agent corrente, quindi c'è self-awareness. +- `available_tools` è il campo più importante per delegare bene: l'LLM vede i tool reali del peer, non deve indovinarli dalla sola descrizione. +- `description` viene presa da `AGENT.md` frontmatter `description` quando presente; altrimenti dal primo paragrafo utile di `AGENT.md`, e in fallback da `SOUL.md`. +- `name` arriva prima da `agents.list[].name`, poi da `AGENT.md` frontmatter `name`, e in fallback dall'ID dell'agent. +- `channels` derivano dal routing: + - l'agent di default espone i canali abilitati + - gli altri agent espongono i canali che hanno un binding esplicito verso di loro + +Forma dell'oggetto iniettato: + +```json +{ + "current_agent_id": "main", + "agents": [ + { + "id": "main", + "name": "Main Assistant", + "description": "Agent generalista per richieste quotidiane.", + "model": "gpt-4o-mini", + "available_tools": ["read_file", "write_file", "exec", "spawn"], + "channels": ["telegram", "discord"] + }, + { + "id": "research", + "name": "Research Agent", + "description": "Specialista per investigazioni e lavoro web.", + "model": "claude-sonnet-4.5", + "available_tools": ["web_search", "web_fetch", "read_file"], + "channels": ["telegram"] + } + ] +} +``` + +In pratica, un agent generalista può vedere che un peer ha `["web_search", "web_fetch"]` mentre lui ha solo tool locali, e scegliere di delegare a quel peer in modo esplicito invece di andare a tentativi. + ### 🔒 Sandbox di Sicurezza PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato. diff --git a/pkg/agent/context.go b/pkg/agent/context.go index c3fcc9fff..31b60e45a 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -22,11 +22,13 @@ import ( type ContextBuilder struct { workspace string + agentID string skillsLoader *skills.SkillsLoader memory *MemoryStore toolDiscoveryBM25 bool toolDiscoveryRegex bool splitOnMarker bool + agentDiscovery func(workspace string) []AgentDescriptor // Cache for system prompt to avoid rebuilding on every call. // This fixes issue #607: repeated reprocessing of the entire context. @@ -58,6 +60,18 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { return cb } +func (cb *ContextBuilder) WithAgentIdentity(agentID string) *ContextBuilder { + cb.agentID = strings.TrimSpace(agentID) + return cb +} + +func (cb *ContextBuilder) WithAgentDiscovery( + discover func(workspace string) []AgentDescriptor, +) *ContextBuilder { + cb.agentDiscovery = discover + return cb +} + func getGlobalConfigDir() string { if home := os.Getenv(config.EnvHome); home != "" { return home @@ -113,7 +127,14 @@ Your workspace is at: %s 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) + version, + workspacePath, + workspacePath, + workspacePath, + workspacePath, + workspacePath, + toolDiscovery, + ) } func (cb *ContextBuilder) getDiscoveryRule() string { @@ -175,6 +196,13 @@ Each part separated by the marker will be sent as an independent message.`) return strings.Join(parts, "\n\n---\n\n") } +func (cb *ContextBuilder) buildAgentDiscoveryContext() string { + if cb.agentDiscovery == nil { + return "" + } + return formatAgentDiscoverySection(cb.agentID, cb.agentDiscovery(cb.workspace)) +} + // BuildSystemPromptWithCache returns the cached system prompt if available // and source files haven't changed, otherwise builds and caches it. // Source file changes are detected via mtime checks (cheap stat calls). @@ -500,7 +528,9 @@ func formatCurrentSenderLine(senderID, senderDisplayName string) string { } } -func (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string { +func (cb *ContextBuilder) buildDynamicContext( + channel, chatID, senderID, senderDisplayName string, +) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) @@ -540,6 +570,7 @@ func (cb *ContextBuilder) BuildMessages( // Build short dynamic context (time, runtime, session) — changes per request dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName) + discoveryCtx := cb.buildAgentDiscoveryContext() // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can @@ -550,16 +581,33 @@ 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}, + { + Type: "text", + Text: staticPrompt, + CacheControl: &providers.CacheControl{Type: "ephemeral"}, + }, } + if discoveryCtx != "" { + stringParts = append(stringParts, discoveryCtx) + contentBlocks = append( + contentBlocks, + providers.ContentBlock{Type: "text", Text: discoveryCtx}, + ) + } + + stringParts = append(stringParts, dynamicCtx) + contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: dynamicCtx}) + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { stringParts = append(stringParts, skillsText) - contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: skillsText}) + contentBlocks = append( + contentBlocks, + providers.ContentBlock{Type: "text", Text: skillsText}, + ) } if summary != "" { @@ -568,7 +616,10 @@ func (cb *ContextBuilder) BuildMessages( "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}) + contentBlocks = append( + contentBlocks, + providers.ContentBlock{Type: "text", Text: summaryText}, + ) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") @@ -667,7 +718,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message case "assistant": if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { - logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) + logger.DebugCF( + "agent", + "Dropping assistant tool-call turn at history start", + map[string]any{}, + ) continue } prev := sanitized[len(sanitized)-1] diff --git a/pkg/agent/context_budget_test.go b/pkg/agent/context_budget_test.go index 870f0fbe6..c8993746f 100644 --- a/pkg/agent/context_budget_test.go +++ b/pkg/agent/context_budget_test.go @@ -500,8 +500,11 @@ func TestEstimateMessageTokens_ReasoningContent(t *testing.T) { reasoningTokens := estimateMessageTokens(withReasoning) if reasoningTokens <= plainTokens { - t.Errorf("message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)", - reasoningTokens, plainTokens) + t.Errorf( + "message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)", + reasoningTokens, + plainTokens, + ) } } @@ -764,7 +767,11 @@ func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) { tokensNoReasoning := estimateMessageTokens(msgNoReasoning) if tokens <= tokensNoReasoning { - t.Errorf("reasoning content should add tokens: with=%d, without=%d", tokens, tokensNoReasoning) + t.Errorf( + "reasoning content should add tokens: with=%d, without=%d", + tokens, + tokensNoReasoning, + ) } } diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 81a1534b9..ae6ff18cc 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -82,7 +82,16 @@ func TestSingleSystemMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "") + msgs := cb.BuildMessages( + tt.history, + tt.summary, + tt.message, + nil, + "test", + "chat1", + "", + "", + ) systemCount := 0 for _, m := range msgs { @@ -168,7 +177,16 @@ func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName) + msgs := cb.BuildMessages( + nil, + "", + "hello", + nil, + "discord", + "chat1", + tt.senderID, + tt.senderDisplayName, + ) sys := msgs[0].Content if tt.wantSection { @@ -382,7 +400,10 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) { // Cache should auto-invalidate because file went from absent -> present sp2 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp2, tt.checkField) { - t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField) + t.Errorf( + "cache not invalidated on new file creation: expected %q in prompt", + tt.checkField, + ) } }) } diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 0d7948eef..c3b9ed6a0 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -151,7 +151,19 @@ func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) { if len(result) != 9 { t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant") + assertRoles( + t, + result, + "user", + "assistant", + "tool", + "tool", + "assistant", + "user", + "assistant", + "tool", + "assistant", + ) } func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) { @@ -170,7 +182,18 @@ func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) { if len(result) != 8 { t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant") + assertRoles( + t, + result, + "user", + "assistant", + "tool", + "tool", + "assistant", + "tool", + "tool", + "assistant", + ) } func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { @@ -304,5 +327,17 @@ func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) { if len(result) != 9 { t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant") + assertRoles( + t, + result, + "user", + "assistant", + "tool", + "assistant", + "user", + "user", + "assistant", + "tool", + "assistant", + ) } diff --git a/pkg/agent/definition_test.go b/pkg/agent/definition_test.go index 5ee996967..b3068d134 100644 --- a/pkg/agent/definition_test.go +++ b/pkg/agent/definition_test.go @@ -61,8 +61,12 @@ Act directly and use tools first. if len(definition.Agent.Frontmatter.Skills) != 2 { t.Fatalf("expected skills to be parsed, got %v", definition.Agent.Frontmatter.Skills) } - if len(definition.Agent.Frontmatter.MCPServers) != 1 || definition.Agent.Frontmatter.MCPServers[0] != "github" { - t.Fatalf("expected mcpServers to be parsed, got %v", definition.Agent.Frontmatter.MCPServers) + if len(definition.Agent.Frontmatter.MCPServers) != 1 || + definition.Agent.Frontmatter.MCPServers[0] != "github" { + t.Fatalf( + "expected mcpServers to be parsed, got %v", + definition.Agent.Frontmatter.MCPServers, + ) } if definition.Agent.Frontmatter.Fields["metadata"] == nil { t.Fatal("expected arbitrary frontmatter fields to remain available") @@ -96,7 +100,10 @@ func TestLoadAgentDefinitionFallsBackToLegacyAgentsMarkdown(t *testing.T) { t.Fatal("expected AGENTS.md to be loaded") } if definition.Agent.RawFrontmatter != "" { - t.Fatalf("legacy AGENTS.md should not have frontmatter, got %q", definition.Agent.RawFrontmatter) + t.Fatalf( + "legacy AGENTS.md should not have frontmatter, got %q", + definition.Agent.RawFrontmatter, + ) } if !strings.Contains(definition.Agent.Body, "Keep compatibility") { t.Fatalf("expected legacy body to be preserved, got %q", definition.Agent.Body) @@ -159,7 +166,10 @@ Keep going. len(definition.Agent.Frontmatter.Skills) != 0 || len(definition.Agent.Frontmatter.MCPServers) != 0 || len(definition.Agent.Frontmatter.Fields) != 0 { - t.Fatalf("expected invalid frontmatter to decode as empty struct, got %+v", definition.Agent.Frontmatter) + t.Fatalf( + "expected invalid frontmatter to decode as empty struct, got %+v", + definition.Agent.Frontmatter, + ) } } diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go new file mode 100644 index 000000000..b630abd60 --- /dev/null +++ b/pkg/agent/discovery.go @@ -0,0 +1,341 @@ +package agent + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/routing" +) + +// AgentDescriptor is the structured discovery payload injected into each +// agent's system prompt so the LLM can make concrete delegation decisions. +type AgentDescriptor struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Model string `json:"model"` + AvailableTools []string `json:"available_tools"` + Channels []string `json:"channels"` +} + +// ListAgents returns structured descriptors for every agent in the current +// PicoClaw instance. The current workspace, when provided, is used only to +// order the matching agent first for prompt readability. +func (r *AgentRegistry) ListAgents(workspace string) []AgentDescriptor { + r.mu.RLock() + defer r.mu.RUnlock() + + ids := make([]string, 0, len(r.agents)) + for id := range r.agents { + ids = append(ids, id) + } + sort.Strings(ids) + + selfWorkspace := cleanWorkspacePath(workspace) + descriptors := make([]AgentDescriptor, 0, len(ids)) + for _, id := range ids { + agent := r.agents[id] + if agent == nil { + continue + } + descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent)) + } + + if selfWorkspace == "" { + return descriptors + } + + sort.SliceStable(descriptors, func(i, j int) bool { + leftSelf := cleanWorkspacePath( + r.workspaceForAgentIDLocked(descriptors[i].ID), + ) == selfWorkspace + rightSelf := cleanWorkspacePath( + r.workspaceForAgentIDLocked(descriptors[j].ID), + ) == selfWorkspace + if leftSelf != rightSelf { + return leftSelf + } + return descriptors[i].ID < descriptors[j].ID + }) + + return descriptors +} + +// GetAgentDescriptor returns the structured discovery payload for one agent. +func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + id := routing.NormalizeAgentID(agentID) + agent, ok := r.agents[id] + if !ok || agent == nil { + return nil, false + } + + descriptor := r.buildAgentDescriptorLocked(agent) + return &descriptor, true +} + +func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor { + definition := loadAgentDefinition(agent.Workspace) + name := strings.TrimSpace(agent.Name) + if name == "" && definition.Agent != nil { + name = strings.TrimSpace(definition.Agent.Frontmatter.Name) + } + if name == "" { + name = agent.ID + } + + return AgentDescriptor{ + ID: agent.ID, + Name: name, + Description: agentDescriptionFromDefinition(definition), + Model: strings.TrimSpace(agent.Model), + AvailableTools: visibleToolNames(agent), + Channels: r.channelsForAgentLocked(agent.ID), + } +} + +func visibleToolNames(agent *AgentInstance) []string { + if agent == nil || agent.Tools == nil { + return []string{} + } + + defs := agent.Tools.ToProviderDefs() + names := make([]string, 0, len(defs)) + for _, def := range defs { + name := strings.TrimSpace(def.Function.Name) + if name == "" { + continue + } + names = append(names, name) + } + if names == nil { + return []string{} + } + return names +} + +func agentDescriptionFromDefinition(definition AgentContextDefinition) string { + if definition.Agent != nil { + if desc := strings.TrimSpace(definition.Agent.Frontmatter.Description); desc != "" { + return desc + } + if desc := firstMeaningfulParagraph(definition.Agent.Body); desc != "" { + return desc + } + } + if definition.Soul != nil { + if desc := firstMeaningfulParagraph(definition.Soul.Content); desc != "" { + return desc + } + } + return "" +} + +func firstMeaningfulParagraph(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + paragraphs := strings.Split(content, "\n\n") + for _, paragraph := range paragraphs { + lines := strings.Split(paragraph, "\n") + parts := make([]string, 0, len(lines)) + inFence := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + inFence = !inFence + continue + } + if inFence || trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") { + trimmed = strings.TrimSpace(trimmed[2:]) + } + parts = append(parts, trimmed) + } + if len(parts) == 0 { + continue + } + return strings.Join(parts, " ") + } + return "" +} + +func (r *AgentRegistry) channelsForAgentLocked(agentID string) []string { + channels := make(map[string]struct{}) + + if defaultID := r.defaultAgentIDLocked(); defaultID != "" && defaultID == agentID { + for _, channel := range enabledChannels(r.cfg) { + channels[channel] = struct{}{} + } + } + + if r.cfg != nil { + for _, binding := range r.cfg.Bindings { + if routing.NormalizeAgentID(binding.AgentID) != agentID { + continue + } + channel := strings.ToLower(strings.TrimSpace(binding.Match.Channel)) + if channel == "" { + continue + } + channels[channel] = struct{}{} + } + } + + if len(channels) == 0 { + return []string{} + } + + result := make([]string, 0, len(channels)) + for channel := range channels { + result = append(result, channel) + } + sort.Strings(result) + return result +} + +func enabledChannels(cfg *config.Config) []string { + if cfg == nil { + return []string{} + } + + enabled := make([]string, 0, 16) + if cfg.Channels.WhatsApp.Enabled { + enabled = append(enabled, "whatsapp") + } + if cfg.Channels.Telegram.Enabled { + enabled = append(enabled, "telegram") + } + if cfg.Channels.Feishu.Enabled { + enabled = append(enabled, "feishu") + } + if cfg.Channels.Discord.Enabled { + enabled = append(enabled, "discord") + } + if cfg.Channels.MaixCam.Enabled { + enabled = append(enabled, "maixcam") + } + if cfg.Channels.QQ.Enabled { + enabled = append(enabled, "qq") + } + if cfg.Channels.DingTalk.Enabled { + enabled = append(enabled, "dingtalk") + } + if cfg.Channels.Slack.Enabled { + enabled = append(enabled, "slack") + } + if cfg.Channels.Matrix.Enabled { + enabled = append(enabled, "matrix") + } + if cfg.Channels.LINE.Enabled { + enabled = append(enabled, "line") + } + if cfg.Channels.OneBot.Enabled { + enabled = append(enabled, "onebot") + } + if cfg.Channels.WeCom.Enabled { + enabled = append(enabled, "wecom") + } + if cfg.Channels.Weixin.Enabled { + enabled = append(enabled, "weixin") + } + if cfg.Channels.Pico.Enabled { + enabled = append(enabled, "pico") + } + if cfg.Channels.PicoClient.Enabled { + enabled = append(enabled, "pico_client") + } + if cfg.Channels.IRC.Enabled { + enabled = append(enabled, "irc") + } + return enabled +} + +func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string { + agent, ok := r.agents[routing.NormalizeAgentID(agentID)] + if !ok || agent == nil { + return "" + } + return agent.Workspace +} + +func (r *AgentRegistry) defaultAgentIDLocked() string { + if _, ok := r.agents[routing.DefaultAgentID]; ok { + return routing.DefaultAgentID + } + if r.cfg != nil && len(r.cfg.Agents.List) > 0 { + for _, agentCfg := range r.cfg.Agents.List { + if !agentCfg.Default { + continue + } + id := routing.NormalizeAgentID(agentCfg.ID) + if _, ok := r.agents[id]; ok { + return id + } + } + id := routing.NormalizeAgentID(r.cfg.Agents.List[0].ID) + if _, ok := r.agents[id]; ok { + return id + } + } + for id := range r.agents { + return id + } + return "" +} + +func cleanWorkspacePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + return filepath.Clean(path) +} + +func formatAgentDiscoverySection(currentAgentID string, agents []AgentDescriptor) string { + if len(agents) <= 1 { + return "" + } + + payload := struct { + CurrentAgentID string `json:"current_agent_id"` + Agents []AgentDescriptor `json:"agents"` + }{ + CurrentAgentID: strings.TrimSpace(currentAgentID), + Agents: agents, + } + + encoded, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "" + } + + var header strings.Builder + header.WriteString("# Agent Discovery\n\n") + if payload.CurrentAgentID != "" { + fmt.Fprintf( + &header, + "You are agent %q. This registry is authoritative for the current PicoClaw instance and includes your own entry.\n", + payload.CurrentAgentID, + ) + } else { + header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") + } + header.WriteString( + "Delegate based on available_tools first, then model, channels, and description. Use only agent IDs listed here.\n\n", + ) + header.WriteString("```json\n") + header.Write(encoded) + header.WriteString("\n```") + + return header.String() +} diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go new file mode 100644 index 000000000..a44f67dea --- /dev/null +++ b/pkg/agent/discovery_test.go @@ -0,0 +1,211 @@ +package agent + +import ( + "slices" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestAgentRegistry_ListAgentsBuildsStructuredDescriptors(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: Main Frontmatter Name +description: Structured main agent +--- +# Agent + +Handle general requests. +`, + }) + defer cleanupWorkspace(t, mainWorkspace) + + supportWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `# Agent + +Handle support tickets carefully. +`, + "SOUL.md": "# Soul\nStay calm and precise.", + }) + defer cleanupWorkspace(t, supportWorkspace) + + cfg := testCfg([]config.AgentConfig{ + {ID: "main", Default: true, Name: "Configured Main", Workspace: mainWorkspace}, + { + ID: "support", + Workspace: supportWorkspace, + Model: &config.AgentModelConfig{Primary: "support-model"}, + }, + }) + cfg.Tools.ReadFile.Enabled = true + cfg.Tools.WriteFile.Enabled = true + cfg.Channels.Telegram.Enabled = true + cfg.Bindings = []config.AgentBinding{ + { + AgentID: "support", + Match: config.BindingMatch{ + Channel: "telegram", + AccountID: "*", + }, + }, + } + + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + + descriptors := registry.ListAgents(mainWorkspace) + if len(descriptors) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(descriptors)) + } + + if descriptors[0].ID != "main" { + t.Fatalf("expected current workspace agent first, got %q", descriptors[0].ID) + } + if descriptors[0].Name != "Configured Main" { + t.Fatalf("expected config name to win, got %q", descriptors[0].Name) + } + if descriptors[0].Description != "Structured main agent" { + t.Fatalf("expected frontmatter description, got %q", descriptors[0].Description) + } + if descriptors[0].Model != "gpt-4" { + t.Fatalf("expected inherited model, got %q", descriptors[0].Model) + } + if !slices.Contains(descriptors[0].AvailableTools, "read_file") || + !slices.Contains(descriptors[0].AvailableTools, "write_file") { + t.Fatalf("expected visible file tools in descriptor, got %v", descriptors[0].AvailableTools) + } + if !slices.Equal(descriptors[0].Channels, []string{"telegram"}) { + t.Fatalf( + "expected default agent to cover enabled telegram channel, got %v", + descriptors[0].Channels, + ) + } + + support, ok := registry.GetAgentDescriptor("support") + if !ok || support == nil { + t.Fatal("expected support descriptor lookup to succeed") + } + if support.Description != "Handle support tickets carefully." { + t.Fatalf("expected AGENT body fallback description, got %q", support.Description) + } + if support.Model != "support-model" { + t.Fatalf("expected explicit support model, got %q", support.Model) + } + if !slices.Equal(support.Channels, []string{"telegram"}) { + t.Fatalf("expected support channel binding, got %v", support.Channels) + } +} + +func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +description: Main agent +--- +# Agent + +Generalist. +`, + }) + defer cleanupWorkspace(t, mainWorkspace) + + researchWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +description: Research specialist +--- +# Agent + +Investigate deeply. +`, + }) + defer cleanupWorkspace(t, researchWorkspace) + + cfg := testCfg([]config.AgentConfig{ + {ID: "main", Default: true, Workspace: mainWorkspace}, + {ID: "research", Workspace: researchWorkspace}, + }) + cfg.Tools.ReadFile.Enabled = true + cfg.Tools.WriteFile.Enabled = true + + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + mainAgent, ok := registry.GetAgent("main") + if !ok || mainAgent == nil { + t.Fatal("expected main agent") + } + + messages := mainAgent.ContextBuilder.BuildMessages( + nil, + "", + "delegate wisely", + nil, + "telegram", + "chat-1", + "", + "", + ) + if len(messages) == 0 { + t.Fatal("expected messages") + } + + systemPrompt := messages[0].Content + if !strings.Contains(systemPrompt, "# Agent Discovery") { + t.Fatalf("expected discovery section in system prompt, got %q", systemPrompt) + } + if !strings.Contains(systemPrompt, `"current_agent_id": "main"`) { + t.Fatalf("expected current agent id in discovery section, got %q", systemPrompt) + } + if !strings.Contains(systemPrompt, `"id": "main"`) || + !strings.Contains(systemPrompt, `"id": "research"`) { + t.Fatalf("expected self and peer descriptors in discovery section, got %q", systemPrompt) + } + if !strings.Contains(systemPrompt, `"available_tools": [`) || + !strings.Contains(systemPrompt, `"read_file"`) || + !strings.Contains(systemPrompt, `"write_file"`) { + t.Fatalf("expected visible tool list in discovery section, got %q", systemPrompt) + } +} + +func TestContextBuilder_BuildMessagesOmitsAgentDiscoverySectionForSingleton(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +description: Main agent +--- +# Agent + +Generalist. +`, + }) + defer cleanupWorkspace(t, mainWorkspace) + + cfg := testCfg([]config.AgentConfig{ + {ID: "main", Default: true, Workspace: mainWorkspace}, + }) + cfg.Tools.ReadFile.Enabled = true + + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + mainAgent, ok := registry.GetAgent("main") + if !ok || mainAgent == nil { + t.Fatal("expected main agent") + } + + messages := mainAgent.ContextBuilder.BuildMessages( + nil, + "", + "handle locally", + nil, + "telegram", + "chat-1", + "", + "", + ) + if len(messages) == 0 { + t.Fatal("expected messages") + } + + systemPrompt := messages[0].Content + if strings.Contains(systemPrompt, "# Agent Discovery") { + t.Fatalf("did not expect discovery section for singleton registry, got %q", systemPrompt) + } + if strings.Contains(systemPrompt, `"current_agent_id": "main"`) { + t.Fatalf("did not expect discovery payload for singleton registry, got %q", systemPrompt) + } +} diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 19a1ea9eb..0b0e351dd 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -275,7 +275,13 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { resultCh := make(chan string, 1) go func() { - resp, _ := al.ProcessDirectWithChannel(context.Background(), "do something", "test-session", "test", "chat1") + resp, _ := al.ProcessDirectWithChannel( + context.Background(), + "do something", + "test-session", + "test", + "chat1", + ) resultCh <- resp }() @@ -338,7 +344,11 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatalf("expected steering interrupt kind, got %q", interruptPayload.Kind) } if interruptPayload.ContentLen != len("change course") { - t.Fatalf("expected interrupt content len %d, got %d", len("change course"), interruptPayload.ContentLen) + t.Fatalf( + "expected interrupt content len %d, got %d", + len("change course"), + interruptPayload.ContentLen, + ) } } @@ -360,7 +370,9 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { }, } - contextErr := stringError("InvalidParameter: Total tokens of image and text exceed max message tokens") + contextErr := stringError( + "InvalidParameter: Total tokens of image and text exceed max message tokens", + ) provider := &failFirstMockProvider{ failures: 1, failError: contextErr, @@ -603,7 +615,12 @@ func collectEventStream(ch <-chan Event) []Event { } } -func waitForEvent(t *testing.T, ch <-chan Event, timeout time.Duration, match func(Event) bool) Event { +func waitForEvent( + t *testing.T, + ch <-chan Event, + timeout time.Duration, + match func(Event) bool, +) Event { t.Helper() timer := time.NewTimer(timeout) diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go index 85d8f5c11..068f8da10 100644 --- a/pkg/agent/hook_mount_test.go +++ b/pkg/agent/hook_mount_test.go @@ -40,7 +40,11 @@ func (h *builtinAutoHook) AfterLLM( return next, HookDecision{Action: HookActionModify}, nil } -func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks config.HooksConfig) *AgentLoop { +func newConfiguredHookLoop( + t *testing.T, + provider *llmHookTestProvider, + hooks config.HooksConfig, +) *AgentLoop { t.Helper() cfg := &config.Config{ @@ -102,7 +106,13 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T) }) defer al.Close() - resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "session-1", + "cli", + "direct", + ) if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -140,7 +150,13 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T) }) defer al.Close() - resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") + resp, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "session-1", + "cli", + "direct", + ) if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -172,7 +188,13 @@ func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testin }) defer al.Close() - _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") + _, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "session-1", + "cli", + "direct", + ) if err == nil { t.Fatal("expected invalid configured hook error") } diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go index e5632913d..9b623ce42 100644 --- a/pkg/agent/hook_process.go +++ b/pkg/agent/hook_process.go @@ -98,7 +98,11 @@ type processHookAfterToolResponse struct { Result *ToolResultHookResponse `json:"result,omitempty"` } -func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (*ProcessHook, error) { +func NewProcessHook( + ctx context.Context, + name string, + opts ProcessHookOptions, +) (*ProcessHook, error) { if len(opts.Command) == 0 { return nil, fmt.Errorf("process hook command is required") } @@ -262,7 +266,10 @@ func (ph *ProcessHook) AfterTool( return resp.Result, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil } -func (ph *ProcessHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { +func (ph *ProcessHook) ApproveTool( + ctx context.Context, + req *ToolApprovalRequest, +) (ApprovalDecision, error) { if ph == nil || !ph.opts.ApproveTool { return ApprovalDecision{Approved: true}, nil } @@ -473,7 +480,11 @@ func (ph *ProcessHook) removePending(id uint64) { } } -func (al *AgentLoop) MountProcessHook(ctx context.Context, name string, opts ProcessHookOptions) error { +func (al *AgentLoop) MountProcessHook( + ctx context.Context, + name string, + opts ProcessHookOptions, +) error { if al == nil { return fmt.Errorf("agent loop is nil") } diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index c1ef58ffd..4f63d0652 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -79,8 +79,14 @@ type LLMInterceptor interface { } type ToolInterceptor interface { - BeforeTool(ctx context.Context, call *ToolCallHookRequest) (*ToolCallHookRequest, HookDecision, error) - AfterTool(ctx context.Context, result *ToolResultHookResponse) (*ToolResultHookResponse, HookDecision, error) + BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, + ) (*ToolCallHookRequest, HookDecision, error) + AfterTool( + ctx context.Context, + result *ToolResultHookResponse, + ) (*ToolResultHookResponse, HookDecision, error) } type ToolApprover interface { @@ -295,7 +301,10 @@ func (hm *HookManager) dispatchEvents() { } } -func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision) { +func (hm *HookManager) BeforeLLM( + ctx context.Context, + req *LLMHookRequest, +) (*LLMHookRequest, HookDecision) { if hm == nil || req == nil { return req, HookDecision{Action: HookActionContinue} } @@ -326,7 +335,10 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM return current, HookDecision{Action: HookActionContinue} } -func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision) { +func (hm *HookManager) AfterLLM( + ctx context.Context, + resp *LLMHookResponse, +) (*LLMHookResponse, HookDecision) { if hm == nil || resp == nil { return resp, HookDecision{Action: HookActionContinue} } diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index 49e1b1784..d112d4c07 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -293,7 +293,10 @@ func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { type denyApprovalHook struct{} -func (h *denyApprovalHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { +func (h *denyApprovalHook) ApproveTool( + ctx context.Context, + req *ToolApprovalRequest, +) (ApprovalDecision, error) { return ApprovalDecision{ Approved: false, Reason: "blocked", diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 880725660..4b3b4b3ee 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -72,12 +72,16 @@ func NewAgentInstance( // Compile path whitelist patterns from config. allowReadPaths := buildAllowReadPatterns(cfg) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) + agentToolAllowlist := resolveAgentToolAllowlist(agentCfg) toolsRegistry := tools.NewToolRegistry() + toolsRegistry.SetAllowlist(agentToolAllowlist) if cfg.Tools.IsToolEnabled("read_file") { maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize - toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + toolsRegistry.Register( + tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths), + ) } if cfg.Tools.IsToolEnabled("write_file") { toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) @@ -180,8 +184,15 @@ func NewAgentInstance( if len(resolved) > 0 { lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace) if err != nil { - logger.WarnCF("agent", "Routing light model config invalid; routing disabled", - map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()}) + logger.WarnCF( + "agent", + "Routing light model config invalid; routing disabled", + map[string]any{ + "light_model": rc.LightModel, + "agent_id": agentID, + "error": err.Error(), + }, + ) } else { lp, _, err := providers.CreateProviderFromConfig(lightModelCfg) if err != nil { @@ -234,7 +245,8 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD return expandHome(strings.TrimSpace(agentCfg.Workspace)) } // Use the configured default workspace (respects PICOCLAW_HOME) - if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" { + if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || + routing.NormalizeAgentID(agentCfg.ID) == "main" { return expandHome(defaults.Workspace) } // For named agents without explicit workspace, use default workspace with agent ID suffix diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index e296a18cb..a933a6493 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -156,7 +156,11 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) } if agent.Candidates[0].Provider != tt.wantProvider { - t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider) + t.Fatalf( + "candidate provider = %q, want %q", + agent.Candidates[0].Provider, + tt.wantProvider, + ) } if agent.Candidates[0].Model != tt.wantModel { t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ef2951365..2193bbad3 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -192,7 +192,11 @@ func registerSharedTools( Proxy: cfg.Tools.Web.Proxy, }) if err != nil { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web search tool", + map[string]any{"error": err.Error()}, + ) } else if searchTool != nil { agent.Tools.Register(searchTool) } @@ -205,7 +209,11 @@ func registerSharedTools( cfg.Tools.Web.FetchLimitBytes, cfg.Tools.Web.PrivateHostWhitelist) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } else { agent.Tools.Register(fetchTool) } @@ -475,7 +483,12 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) + continued, continueErr := al.Continue( + ctx, + target.SessionKey, + target.Channel, + target.ChatID, + ) if continueErr != nil { logger.WarnCF("agent", "Failed to continue queued steering", map[string]any{ @@ -503,14 +516,22 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) + continued, continueErr := al.Continue( + ctx, + target.SessionKey, + target.Channel, + target.ChatID, + ) if continueErr != nil { - logger.WarnCF("agent", "Failed to continue queued steering after shutdown drain", + logger.WarnCF( + "agent", + "Failed to continue queued steering after shutdown drain", map[string]any{ "channel": target.Channel, "chat_id": target.ChatID, "error": continueErr.Error(), - }) + }, + ) return } if continued == "" { @@ -565,11 +586,15 @@ func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, active msgScope, _, scopeOK := al.resolveSteeringTarget(msg) if !scopeOK || msgScope != activeScope { if err := al.requeueInboundMessage(msg); err != nil { - logger.WarnCF("agent", "Failed to requeue non-steering inbound message", map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - "sender_id": msg.SenderID, - }) + logger.WarnCF( + "agent", + "Failed to requeue non-steering inbound message", + map[string]any{ + "error": err.Error(), + "channel": msg.Channel, + "sender_id": msg.SenderID, + }, + ) } continue } @@ -603,7 +628,10 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } -func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { +func (al *AgentLoop) PublishResponseIfNeeded( + ctx context.Context, + channel, chatID, response string, +) { if response == "" { return } @@ -1053,7 +1081,10 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and // replaces audio annotations in msg.Content with the transcribed text. // Returns the (possibly modified) message and true if audio was transcribed. -func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { +func (al *AgentLoop) transcribeAudioInMessage( + ctx context.Context, + msg bus.InboundMessage, +) (bus.InboundMessage, bool) { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { return msg, false } @@ -1063,7 +1094,11 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou for _, ref := range msg.Media { path, meta, err := al.mediaStore.ResolveWithMeta(ref) if err != nil { - logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + logger.WarnCF( + "voice", + "Failed to resolve media ref", + map[string]any{"ref": ref, "error": err}, + ) continue } if !utils.IsAudioFile(meta.Filename, meta.ContentType) { @@ -1141,7 +1176,11 @@ func (al *AgentLoop) sendTranscriptionFeedback( ReplyToMessageID: messageID, }) if err != nil { - logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) + logger.WarnCF( + "voice", + "Failed to send transcription feedback", + map[string]any{"error": err.Error()}, + ) } } @@ -1342,7 +1381,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.runAgentLoop(ctx, agent, opts) } -func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { +func (al *AgentLoop) resolveMessageRoute( + msg bus.InboundMessage, +) (routing.ResolvedRoute, *AgentInstance, error) { registry := al.GetRegistry() route := registry.ResolveRoute(routing.RouteInput{ Channel: msg.Channel, @@ -1358,7 +1399,10 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv agent = registry.GetDefaultAgent() } if agent == nil { - return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + return routing.ResolvedRoute{}, nil, fmt.Errorf( + "no agent available for route (agent_id=%s)", + route.AgentID, + ) } return route, agent, nil @@ -1683,7 +1727,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er ts.recordPersistedMessage(rootMsg) } - activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) + activeCandidates, activeModel, usedLight := al.selectCandidates( + ts.agent, + ts.userMessage, + messages, + ) activeProvider := ts.agent.Provider if usedLight && ts.agent.LightProvider != nil { activeProvider = ts.agent.LightProvider @@ -2656,12 +2704,15 @@ turnLoop: } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", + logger.InfoCF( + "agent", + "Steering arrived after turn completion; continuing turn before finalizing", map[string]any{ "agent_id": ts.agent.ID, "steering_count": len(steerMsgs), "session_key": ts.sessionKey, - }) + }, + ) pendingMessages = append(pendingMessages, steerMsgs...) finalContent = "" goto turnLoop @@ -2777,11 +2828,18 @@ func (al *AgentLoop) selectCandidates( "score": score, "threshold": agent.Router.Threshold(), }) - return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true + return agent.LightCandidates, resolvedCandidateModel( + agent.LightCandidates, + agent.Router.LightModel(), + ), true } // maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { +func (al *AgentLoop) maybeSummarize( + agent *AgentInstance, + sessionKey string, + turnScope turnEventScope, +) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 @@ -2815,7 +2873,10 @@ type compressionResult struct { // prompt is built dynamically by BuildMessages and is NOT stored here. // The compression note is recorded in the session summary so that // BuildMessages can include it in the next system prompt. -func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) { +func (al *AgentLoop) forceCompression( + agent *AgentInstance, + sessionKey string, +) (compressionResult, bool) { history := agent.Sessions.GetHistory(sessionKey) if len(history) <= 2 { return compressionResult{}, false @@ -2968,7 +3029,11 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { } // summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { +func (al *AgentLoop) summarizeSession( + agent *AgentInstance, + sessionKey string, + turnScope turnEventScope, +) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -3320,7 +3385,10 @@ func (al *AgentLoop) applyExplicitSkillCommand( skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) if !ok { - return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) + return true, true, fmt.Sprintf( + "Unknown skill: %s\nUse /list skills to see installed skills.", + arg, + ) } if len(parts) < 3 { @@ -3347,7 +3415,10 @@ func (al *AgentLoop) applyExplicitSkillCommand( return true, false, "" } -func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime { +func (al *AgentLoop) buildCommandsRuntime( + agent *AgentInstance, + opts *processOptions, +) *commands.Runtime { registry := al.GetRegistry() cfg := al.GetConfig() rt := &commands.Runtime{ @@ -3391,7 +3462,10 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt rt.ListSkillNames = agent.ContextBuilder.ListSkillNames } rt.GetModelInfo = func() (string, string) { - return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) + return agent.Model, resolvedCandidateProvider( + agent.Candidates, + cfg.Agents.Defaults.Provider, + ) } rt.SwitchModel = func(value string) (string, error) { value = strings.TrimSpace(value) @@ -3405,7 +3479,12 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return "", fmt.Errorf("failed to initialize model %q: %w", value, err) } - nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks) + nextCandidates := resolveModelCandidates( + cfg, + cfg.Agents.Defaults.Provider, + modelCfg.Model, + agent.Fallbacks, + ) if len(nextCandidates) == 0 { return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) } diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index 97debbc33..644f7168e 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -65,7 +65,11 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 { - logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil) + logger.WarnCF( + "agent", + "MCP is enabled but no servers are configured, skipping MCP initialization", + nil, + ) return nil } @@ -76,7 +80,11 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } } if !findValidServer { - logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil) + logger.WarnCF( + "agent", + "MCP is enabled but no valid servers are configured, skipping MCP initialization", + nil, + ) return nil } @@ -193,10 +201,14 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } if useRegex { - agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) + agent.Tools.Register( + tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults), + ) } if useBM25 { - agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) + agent.Tools.Register( + tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults), + ) } } } diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go index e8314c10d..6958f51cb 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/loop_media.go @@ -25,7 +25,11 @@ import ( // 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. // Returns a new slice; original messages are not mutated. -func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { +func resolveMediaRefs( + messages []providers.Message, + store media.MediaStore, + maxSize int, +) []providers.Message { if store == nil { return messages } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 25d20c689..9911c5cb7 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -591,7 +591,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. store := media.NewFileMediaStore() al.SetMediaStore(store) telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} - al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) + al.SetChannelManager( + newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel), + ) imagePath := filepath.Join(tmpDir, "screen.png") if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { @@ -613,7 +615,10 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. t.Fatalf("processMessage() error = %v", err) } if response != "" { - t.Fatalf("expected no final response when media tool already handled delivery, got %q", response) + t.Fatalf( + "expected no final response when media tool already handled delivery, got %q", + response, + ) } if provider.calls != 1 { t.Fatalf("expected exactly 1 LLM call, got %d", provider.calls) @@ -626,13 +631,20 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. } if len(telegramChannel.sentMedia) != 1 { - t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) + t.Fatalf( + "expected exactly 1 synchronously sent media message, got %d", + len(telegramChannel.sentMedia), + ) } - if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" { + if telegramChannel.sentMedia[0].Channel != "telegram" || + telegramChannel.sentMedia[0].ChatID != "chat1" { t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0]) } if len(telegramChannel.sentMedia[0].Parts) != 1 { - t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts)) + t.Fatalf( + "expected exactly 1 sent media part, got %d", + len(telegramChannel.sentMedia[0].Parts), + ) } select { @@ -660,7 +672,8 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. t.Fatal("expected session history to be saved") } last := history[len(history)-1] - if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." { + if last.Role != "assistant" || + last.Content != "Requested output delivered via tool attachment." { t.Fatalf("expected handled assistant summary in history, got %+v", last) } } @@ -685,7 +698,9 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes store := media.NewFileMediaStore() al.SetMediaStore(store) telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} - al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) + al.SetChannelManager( + newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel), + ) imagePath := filepath.Join(tmpDir, "screen-steering.png") if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { @@ -714,7 +729,10 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes t.Fatalf("expected 2 LLM calls after queued steering, got %d", provider.calls) } if len(telegramChannel.sentMedia) != 1 { - t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) + t.Fatalf( + "expected exactly 1 synchronously sent media message, got %d", + len(telegramChannel.sentMedia), + ) } } @@ -733,7 +751,9 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { store := media.NewFileMediaStore() al.SetMediaStore(store) telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} - al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) + al.SetChannelManager( + newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel), + ) mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { @@ -766,13 +786,20 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { } if len(telegramChannel.sentMedia) != 1 { - t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) + t.Fatalf( + "expected exactly 1 synchronously sent media message, got %d", + len(telegramChannel.sentMedia), + ) } - if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" { + if telegramChannel.sentMedia[0].Channel != "telegram" || + telegramChannel.sentMedia[0].ChatID != "chat1" { t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0]) } if len(telegramChannel.sentMedia[0].Parts) != 1 { - t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts)) + t.Fatalf( + "expected exactly 1 sent media part, got %d", + len(telegramChannel.sentMedia[0].Parts), + ) } select { @@ -1183,7 +1210,10 @@ func (m *handledMediaWithSteeringTool) Parameters() map[string]any { } } -func (m *handledMediaWithSteeringTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { +func (m *handledMediaWithSteeringTool) Execute( + ctx context.Context, + args map[string]any, +) *tools.ToolResult { if err := m.loop.Steer(providers.Message{Role: "user", Content: "what about this instead?"}); err != nil { return tools.ErrorResult(err.Error()).WithError(err) } @@ -1336,7 +1366,11 @@ func newStrictChatCompletionTestServer( })) } -func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { +func (h testHelper) executeAndGetResponse( + tb testing.TB, + ctx context.Context, + msg bus.InboundMessage, +) string { // Use a short timeout to avoid hanging timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) defer cancel() @@ -1467,7 +1501,10 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { t.Fatalf("unexpected /foo reply: %q", fooResp) } if provider.calls != 1 { - t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls) + t.Fatalf( + "LLM should be called exactly once after /foo passthrough, calls=%d", + provider.calls, + ) } newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -1617,7 +1654,10 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { } if provider.calls != 0 { - t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls) + t.Fatalf( + "LLM should not be called for rejected /switch and /show, calls=%d", + provider.calls, + ) } } @@ -1635,7 +1675,13 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t remoteCalls := 0 remoteModel := "" - remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel) + remoteServer := newChatCompletionTestServer( + t, + "remote", + "remote reply", + &remoteCalls, + &remoteModel, + ) defer remoteServer.Close() cfg := &config.Config{ @@ -1958,7 +2004,9 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { msgBus := bus.NewMessageBus() // Create a provider that fails once with a context error - contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens") + contextErr := fmt.Errorf( + "InvalidParameter: Total tokens of image and text exceed max message tokens", + ) provider := &failFirstMockProvider{ failures: 1, failError: contextErr, @@ -2039,7 +2087,13 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) { provider := &simpleMockProvider{response: ""} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1") + response, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "empty-response", + "test", + "chat1", + ) if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -2071,7 +2125,13 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { al := NewAgentLoop(cfg, msgBus, provider) al.RegisterTool(&toolLimitTestTool{}) - response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1") + response, err := al.ProcessDirectWithChannel( + context.Background(), + "hello", + "tool-limit", + "test", + "chat1", + ) if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -2389,7 +2449,9 @@ func TestHandleReasoning(t *testing.T) { break } if msg.Content == "should timeout" { - t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") + t.Fatal( + "expected reasoning message to be dropped when bus is full, but it was published", + ) } } } @@ -2483,7 +2545,12 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { provider := &toolFeedbackProvider{filePath: heartbeatFile} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1") + response, err := al.ProcessHeartbeat( + context.Background(), + "check heartbeat tasks", + "telegram", + "chat-1", + ) if err != nil { t.Fatalf("ProcessHeartbeat() error = %v", err) } @@ -2968,8 +3035,14 @@ func TestProcessMessage_ContextOverflowRecovery(t *testing.T) { agent := al.GetRegistry().GetDefaultAgent() for i := 0; i < 5; i++ { - agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "heavy message"}) - agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"}) + agent.Sessions.AddFullMessage( + sessionKey, + providers.Message{Role: "user", Content: "heavy message"}, + ) + agent.Sessions.AddFullMessage( + sessionKey, + providers.Message{Role: "assistant", Content: "response"}, + ) } response, err := al.processMessage(context.Background(), bus.InboundMessage{ diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 140cff718..d5c2f74ea 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -26,7 +26,8 @@ func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) return "", false } - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && + strings.TrimSpace(mc.Model) != "" { return ensureProtocol(mc.Model), true } @@ -78,7 +79,10 @@ func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallbac return fallback } -func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { +func resolvedModelConfig( + cfg *config.Config, + modelName, workspace string, +) (*config.ModelConfig, error) { if cfg == nil { return nil, fmt.Errorf("config is nil") } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 58b7ce440..46f54f5c8 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -12,6 +12,7 @@ import ( // AgentRegistry manages multiple agent instances and routes messages to them. type AgentRegistry struct { + cfg *config.Config agents map[string]*AgentInstance resolver *routing.RouteResolver mu sync.RWMutex @@ -23,6 +24,7 @@ func NewAgentRegistry( provider providers.LLMProvider, ) *AgentRegistry { registry := &AgentRegistry{ + cfg: cfg, agents: make(map[string]*AgentInstance), resolver: routing.NewRouteResolver(cfg), } @@ -52,6 +54,14 @@ func NewAgentRegistry( } } + for id, instance := range registry.agents { + if instance.ContextBuilder != nil { + instance.ContextBuilder. + WithAgentIdentity(id). + WithAgentDiscovery(registry.ListAgents) + } + } + return registry } @@ -130,11 +140,13 @@ func (r *AgentRegistry) Close() { func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() defer r.mu.RUnlock() - if agent, ok := r.agents["main"]; ok { - return agent + if id := r.defaultAgentIDLocked(); id != "" { + if agent, ok := r.agents[id]; ok { + return agent + } } - for _, agent := range r.agents { - return agent + for id := range r.agents { + return r.agents[id] } return nil } diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index b173ef967..2b577ab93 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -2,8 +2,10 @@ package agent import ( "context" + "slices" "testing" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -200,6 +202,77 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { agent, _ := registry.GetAgent("no-fallback") if len(agent.Fallbacks) != 0 { - t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks) + t.Errorf( + "expected 0 fallbacks (explicit empty), got %d: %v", + len(agent.Fallbacks), + agent.Fallbacks, + ) + } +} + +func TestNewAgentLoop_AgentToolAllowlistFiltersRuntimeTools(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + {ID: "main", Default: true}, + { + ID: "research", + Tools: []string{"read_file", "write_file", "web_search", "web_fetch", "message"}, + }, + }) + cfg.Tools.ReadFile.Enabled = true + cfg.Tools.WriteFile.Enabled = true + cfg.Tools.ListDir.Enabled = true + cfg.Tools.Exec.Enabled = true + cfg.Tools.Message.Enabled = true + cfg.Tools.Web.Enabled = true + cfg.Tools.Web.DuckDuckGo.Enabled = true + cfg.Tools.WebFetch.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + research, ok := al.GetRegistry().GetAgent("research") + if !ok || research == nil { + t.Fatal("expected research agent") + } + + got := research.Tools.List() + want := []string{"message", "read_file", "web_fetch", "web_search", "write_file"} + if !slices.Equal(got, want) { + t.Fatalf("research tools = %v, want %v", got, want) + } + + for _, blocked := range []string{"exec", "list_dir", "spawn", "subagent"} { + if _, ok := research.Tools.Get(blocked); ok { + t.Fatalf("expected %q to be blocked by allowlist", blocked) + } + } +} + +func TestNewAgentLoop_AgentToolAllowlistRequiresExactRuntimeToolNames(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + {ID: "main", Default: true}, + { + ID: "research", + Tools: []string{"web"}, + }, + }) + cfg.Tools.Web.Enabled = true + cfg.Tools.Web.DuckDuckGo.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + research, ok := al.GetRegistry().GetAgent("research") + if !ok || research == nil { + t.Fatal("expected research agent") + } + + if _, ok := research.Tools.Get("web_search"); ok { + t.Fatal("web_search should not be registered when allowlist contains only web") + } + if slices.Contains(research.Tools.List(), "web_search") { + t.Fatalf("research tools = %v, expected web_search to be absent", research.Tools.List()) } } diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index ad6613e8c..7ce918dd8 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -325,7 +325,10 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { // user has since enqueued steering messages. // // If no steering messages are pending, it returns an empty string. -func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) { +func (al *AgentLoop) Continue( + ctx context.Context, + sessionKey, channel, chatID string, +) (string, error) { if active := al.GetActiveTurn(); active != nil { return "", fmt.Errorf("turn %s is still active", active.TurnID) } diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 75ba9861d..deb4f07c5 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -896,7 +896,10 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { defer cancelNoExtra() select { case out2 := <-msgBus.OutboundChan(): - t.Fatalf("expected stale direct response to be suppressed, got extra outbound %q", out2.Content) + t.Fatalf( + "expected stale direct response to be suppressed, got extra outbound %q", + out2.Content, + ) case <-noExtraCtx.Done(): } @@ -1044,7 +1047,11 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { if err = os.WriteFile(pngPath, pngHeader, 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } - ref, err := store.Store(pngPath, media.MediaMeta{Filename: "steer.png", ContentType: "image/png"}, "test") + ref, err := store.Store( + pngPath, + media.MediaMeta{Filename: "steer.png", ContentType: "image/png"}, + "test", + ) if err != nil { t.Fatalf("Store failed: %v", err) } @@ -1236,7 +1243,10 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { t.Fatalf("expected 2 provider calls, got %d", calls) } if terminalToolsCount != 0 { - t.Fatalf("expected graceful terminal call to disable tools, got %d tool defs", terminalToolsCount) + t.Fatalf( + "expected graceful terminal call to disable tools, got %d tool defs", + terminalToolsCount, + ) } foundHint := false @@ -1247,7 +1257,8 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { if msg.Role == "user" && msg.Content == expectedHint { foundHint = true } - if msg.Role == "tool" && msg.ToolCallID == "call_2" && msg.Content == "Skipped due to graceful interrupt." { + if msg.Role == "tool" && msg.ToolCallID == "call_2" && + msg.Content == "Skipped due to graceful interrupt." { foundSkipped = true } } @@ -1539,7 +1550,8 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { foundSkipped := false for _, m := range msgs { - if m.Role == "tool" && m.ToolCallID == "call_2" && m.Content == "Skipped due to queued user message." { + if m.Role == "tool" && m.ToolCallID == "call_2" && + m.Content == "Skipped due to queued user message." { foundSkipped = true break } @@ -1547,7 +1559,13 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { if !foundSkipped { // Log what we actually got for i, m := range msgs { - t.Logf("msg[%d]: role=%s toolCallID=%s content=%s", i, m.Role, m.ToolCallID, truncate(m.Content, 80)) + t.Logf( + "msg[%d]: role=%s toolCallID=%s content=%s", + i, + m.Role, + m.ToolCallID, + truncate(m.Content, 80), + ) } t.Fatal("expected skipped tool result for call_2") } diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index f5ba412ab..4fcbb089c 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -505,7 +505,12 @@ func spawnSubTurn( // Event emissions: // - SubTurnResultDeliveredEvent: successful delivery to channel // - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full) -func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, result *tools.ToolResult) { +func deliverSubTurnResult( + al *AgentLoop, + parentTS *turnState, + childID string, + result *tools.ToolResult, +) { // Let GC clean up the pendingResults channel; parent Finish will no longer close it. // We use defer/recover to catch any unlikely channel panics if it were ever closed. defer func() { @@ -516,9 +521,14 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re "recover": r, }) if result != nil && al != nil { - al.emitEvent(EventKindSubTurnOrphan, + al.emitEvent( + EventKindSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), - SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "panic"}, + SubTurnOrphanPayload{ + ParentTurnID: parentTS.turnID, + ChildTurnID: childID, + Reason: "panic", + }, ) } } @@ -531,9 +541,14 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re // If parent turn has already finished, treat this as an orphan result if isFinished || resultChan == nil { if result != nil && al != nil { - al.emitEvent(EventKindSubTurnOrphan, + al.emitEvent( + EventKindSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), - SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"}, + SubTurnOrphanPayload{ + ParentTurnID: parentTS.turnID, + ChildTurnID: childID, + Reason: "parent_finished", + }, ) } return diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 6a2ba835d..ef5a03b20 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -571,7 +571,8 @@ func TestHardAbortSessionRollback(t *testing.T) { } // Verify the content matches the initial state - if finalHistory[0].Content != "initial message 1" || finalHistory[1].Content != "initial response 1" { + if finalHistory[0].Content != "initial message 1" || + finalHistory[1].Content != "initial response 1" { t.Error("history content does not match initial state after rollback") } } @@ -1290,7 +1291,12 @@ func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) { finalOrphan := orphanCount mu.Unlock() - t.Logf("Delivered: %d, Orphan: %d, Total: %d", finalDelivered, finalOrphan, finalDelivered+finalOrphan) + t.Logf( + "Delivered: %d, Orphan: %d, Total: %d", + finalDelivered, + finalOrphan, + finalDelivered+finalOrphan, + ) // With the new drainPendingResults behavior, the total events may be >= numResults // because Finish() drains remaining results from the channel and emits them as orphans. diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go new file mode 100644 index 000000000..41b1fb98b --- /dev/null +++ b/pkg/agent/tool_allowlist.go @@ -0,0 +1,30 @@ +package agent + +import ( + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func resolveAgentToolAllowlist(agentCfg *config.AgentConfig) []string { + if agentCfg == nil || agentCfg.Tools == nil { + return nil + } + + allowlist := make(map[string]struct{}, len(agentCfg.Tools)) + for _, raw := range agentCfg.Tools { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + continue + } + allowlist[trimmed] = struct{}{} + } + + result := make([]string, 0, len(allowlist)) + for name := range allowlist { + result = append(result, name) + } + sort.Strings(result) + return result +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 533f45a44..8f793526b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -106,18 +106,18 @@ const CurrentVersion = 1 // Config is the current config structure with version support type Config struct { - Version int `json:"version" yaml:"-"` // Config schema version for migration - Agents AgentsConfig `json:"agents" yaml:"-"` - Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` - Session SessionConfig `json:"session,omitempty" yaml:"-"` - Channels ChannelsConfig `json:"channels" yaml:"channels"` - ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway" yaml:"-"` - Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` - Tools ToolsConfig `json:"tools" yaml:",inline"` - Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` - Devices DevicesConfig `json:"devices" yaml:"-"` - Voice VoiceConfig `json:"voice" yaml:"-"` + Version int `json:"version" yaml:"-"` // Config schema version for migration + Agents AgentsConfig `json:"agents" yaml:"-"` + Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` + Session SessionConfig `json:"session,omitempty" yaml:"-"` + Channels ChannelsConfig `json:"channels" yaml:"channels"` + ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway" yaml:"-"` + Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` + Tools ToolsConfig `json:"tools" yaml:",inline"` + Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` + Devices DevicesConfig `json:"devices" yaml:"-"` + Voice VoiceConfig `json:"voice" yaml:"-"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"` @@ -248,6 +248,7 @@ type AgentConfig struct { Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` + Tools []string `json:"tools,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } @@ -818,8 +819,8 @@ type GLMSearchConfig struct { BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". - SearchEngine string `json:"search_engine" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` - MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` + SearchEngine string `json:"search_engine" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` } type BaiduSearchConfig struct { @@ -830,7 +831,7 @@ type BaiduSearchConfig struct { } type WebToolsConfig struct { - ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` + ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` @@ -843,13 +844,13 @@ type WebToolsConfig struct { // the client-side web_search tool is hidden to avoid duplicate search surfaces, // and the provider's built-in search is used instead. Falls back to client-side // search when the provider does not support native search. - PreferNative bool `json:"prefer_native" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` + PreferNative bool `yaml:"-" json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. - Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` - Format string `json:"format,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FORMAT"` - PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` + Proxy string `yaml:"-" json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` + FetchLimitBytes int64 `yaml:"-" json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` + Format string `yaml:"-" json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` + PrivateHostWhitelist FlexibleStringSlice `yaml:"-" json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { @@ -887,37 +888,37 @@ type ReadFileToolConfig struct { } type ToolsConfig struct { - AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` - AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` + AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` // FilterSensitiveData controls whether to filter sensitive values (API keys, // tokens, secrets) from tool results before sending to the LLM. // Default: true (enabled) - FilterSensitiveData bool `json:"filter_sensitive_data" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` + FilterSensitiveData bool `json:"filter_sensitive_data" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` // FilterMinLength is the minimum content length required for filtering. // Content shorter than this will be returned unchanged for performance. // Default: 8 - FilterMinLength int `json:"filter_min_length" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` - Web WebToolsConfig `json:"web" yaml:"web,omitempty"` - Cron CronToolsConfig `json:"cron" yaml:"-"` - Exec ExecConfig `json:"exec" yaml:"-"` - Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` - MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` - MCP MCPConfig `json:"mcp" yaml:"-"` - AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` - EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` - FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` - I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"` - InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` - 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_"` - SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` - Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` - SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` - SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` - Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` - WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` - WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` + FilterMinLength int `json:"filter_min_length" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` + Web WebToolsConfig `json:"web" yaml:"web,omitempty"` + Cron CronToolsConfig `json:"cron" yaml:"-"` + Exec ExecConfig `json:"exec" yaml:"-"` + Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` + MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` + MCP MCPConfig `json:"mcp" yaml:"-"` + AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` + EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` + FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` + I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"` + InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + 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_"` + SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` + Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` + SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` + SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` + Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` + WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled @@ -985,10 +986,10 @@ type MCPServerConfig struct { // MCPConfig defines configuration for all MCP servers type MCPConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` Discovery ToolDiscoveryConfig ` json:"discovery"` // Servers is a map of server name to server configuration - Servers map[string]MCPServerConfig `json:"servers,omitempty"` + Servers map[string]MCPServerConfig ` json:"servers,omitempty"` } func LoadConfig(path string) (*Config, error) { @@ -999,7 +1000,10 @@ func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - logger.WarnF("config file not found, using default config", map[string]any{"path": path}) + logger.WarnF( + "config file not found, using default config", + map[string]any{"path": path}, + ) return DefaultConfig(), nil } logger.Errorf("failed to read config file: %v", err) @@ -1022,7 +1026,10 @@ func LoadConfig(path string) (*Config, error) { var cfg *Config switch versionInfo.Version { case 0: - logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + logger.InfoF( + "config migrate start", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) // Legacy config (no version field) v, e := loadConfigV0(data) if e != nil { @@ -1030,10 +1037,16 @@ func LoadConfig(path string) (*Config, error) { } cfg, e = v.Migrate() if e != nil { - logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + logger.ErrorF( + "config migrate fail", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) return nil, e } - logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + logger.InfoF( + "config migrate success", + map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, + ) err = makeBackup(path) if err != nil { return nil, err @@ -1041,7 +1054,10 @@ func LoadConfig(path string) (*Config, error) { // Load existing security config and merge with migrated one to prevent data loss secErr := loadSecurityConfig(cfg, securityPath(path)) if secErr != nil && !os.IsNotExist(secErr) { - logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr}) + logger.WarnF( + "failed to load existing security config during migration", + map[string]any{"error": secErr}, + ) return nil, fmt.Errorf("failed to load existing security config: %w", secErr) } defer func(cfg *Config) { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 75eb458b8..a22bcd7cb 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -113,17 +113,18 @@ func TestAgentConfig_FullParse(t *testing.T) { "name": "Sales Bot", "model": "gpt-4" }, - { - "id": "support", - "name": "Support Bot", - "model": { - "primary": "claude-opus", - "fallbacks": ["haiku"] - }, - "subagents": { - "allow_agents": ["sales"] - } + { + "id": "support", + "name": "Support Bot", + "model": { + "primary": "claude-opus", + "fallbacks": ["haiku"] + }, + "tools": ["read_file", "web_search"], + "subagents": { + "allow_agents": ["sales"] } + } ] }, "bindings": [ @@ -171,6 +172,10 @@ func TestAgentConfig_FullParse(t *testing.T) { if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" { t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) } + if len(support.Tools) != 2 || support.Tools[0] != "read_file" || + support.Tools[1] != "web_search" { + t.Errorf("support.Tools = %v", support.Tools) + } if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 { t.Errorf("support.Subagents = %+v", support.Subagents) } @@ -182,7 +187,8 @@ func TestAgentConfig_FullParse(t *testing.T) { if binding.AgentID != "support" || binding.Match.Channel != "telegram" { t.Errorf("binding = %+v", binding) } - if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" { + if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || + binding.Match.Peer.ID != "user123" { t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer) } @@ -387,7 +393,9 @@ func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) { t.Fatalf("LoadConfig failed: %v", err) } if loaded.Channels.Telegram.Placeholder.Enabled { - t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip") + t.Fatal( + "telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip", + ) } } @@ -510,7 +518,9 @@ func TestLoadConfig_ToolFeedbackDefaultsFalseWhenUnset(t *testing.T) { t.Fatalf("LoadConfig() error: %v", err) } if cfg.Agents.Defaults.ToolFeedback.Enabled { - t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file") + t.Fatal( + "agents.defaults.tool_feedback.enabled should remain false when unset in config file", + ) } } @@ -764,7 +774,10 @@ func TestDefaultConfig_SummarizationThresholds(t *testing.T) { cfg := DefaultConfig() if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 { - t.Errorf("SummarizeMessageThreshold = %d, want 20", cfg.Agents.Defaults.SummarizeMessageThreshold) + t.Errorf( + "SummarizeMessageThreshold = %d, want 20", + cfg.Agents.Defaults.SummarizeMessageThreshold, + ) } if cfg.Agents.Defaults.SummarizeTokenPercent != 75 { t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent) @@ -806,7 +819,11 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { want := filepath.Join("/custom/picoclaw/home", "workspace") if cfg.Agents.Defaults.Workspace != want { - t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) + t.Errorf( + "Workspace path with PICOCLAW_HOME = %q, want %q", + cfg.Agents.Defaults.Workspace, + want, + ) } } @@ -885,7 +902,12 @@ func TestFlexibleStringSlice_UnmarshalText(t *testing.T) { } if len(f) != len(tt.expected) { - t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected)) + t.Errorf( + "UnmarshalText(%q) length = %d, want %d", + tt.input, + len(f), + len(tt.expected), + ) return } @@ -1006,7 +1028,8 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := []string(cfg.Channels.Telegram.Placeholder.Text); len(got) != 1 || got[0] != "Thinking..." { + if got := []string(cfg.Channels.Telegram.Placeholder.Text); len(got) != 1 || + got[0] != "Thinking..." { t.Fatalf("placeholder.text = %#v, want [\"Thinking...\"]", got) } } @@ -1196,9 +1219,21 @@ func TestSaveConfig_MixedKeys(t *testing.T) { cfg := &Config{ Version: CurrentVersion, ModelList: []*ModelConfig{ - {ModelName: "plain", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-new-plaintext")}, - {ModelName: "enc", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings(alreadyEncrypted)}, - {ModelName: "file", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("file://api.key")}, + { + ModelName: "plain", + Model: "openai/gpt-4", + APIKeys: SimpleSecureStrings("sk-new-plaintext"), + }, + { + ModelName: "enc", + Model: "openai/gpt-4", + APIKeys: SimpleSecureStrings(alreadyEncrypted), + }, + { + ModelName: "file", + Model: "openai/gpt-4", + APIKeys: SimpleSecureStrings("file://api.key"), + }, }, } if err := SaveConfig(cfgPath, cfg); err != nil { @@ -1335,7 +1370,10 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile)) if !strings.Contains(string(raw), "enc://") { - t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) + t.Errorf( + "SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", + raw, + ) } } @@ -1587,9 +1625,13 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { FilterMinLength: 8, // Web tool API keys Web: WebToolsConfig{ - Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}}, - Tavily: TavilyConfig{APIKeys: SecureStrings{NewSecureString("tavily-api-key")}}, - Perplexity: PerplexityConfig{APIKeys: SecureStrings{NewSecureString("perplexity-api-key")}}, + Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}}, + Tavily: TavilyConfig{ + APIKeys: SecureStrings{NewSecureString("tavily-api-key")}, + }, + Perplexity: PerplexityConfig{ + APIKeys: SecureStrings{NewSecureString("perplexity-api-key")}, + }, GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")}, BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")}, }, @@ -1597,7 +1639,9 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { Skills: SkillsToolsConfig{ Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")}, Registries: SkillsRegistriesConfig{ - ClawHub: ClawHubRegistryConfig{AuthToken: *NewSecureString("clawhub-auth-token")}, + ClawHub: ClawHubRegistryConfig{ + AuthToken: *NewSecureString("clawhub-auth-token"), + }, }, }, }, diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index bc4ab0649..20e2e531d 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -65,7 +65,11 @@ func DefaultConfig() *Config { Enabled: true, Text: FlexibleStringSlice{"Thinking... 💭"}, }, - Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200}, + Streaming: StreamingConfig{ + Enabled: true, + ThrottleSeconds: 3, + MinGrowthChars: 200, + }, UseMarkdownV2: false, }, Feishu: FeishuConfig{ diff --git a/pkg/config/migration.go b/pkg/config/migration.go index fee800a76..43f9645a2 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -335,7 +335,8 @@ func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 { providerNames: []string{"github_copilot", "copilot"}, protocol: "github-copilot", buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { - if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" { + if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && + p.GitHubCopilot.ConnectMode == "" { return modelConfigV0{}, false } return modelConfigV0{ diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go index bc8160967..b6a70c2ef 100644 --- a/pkg/config/migration_integration_test.go +++ b/pkg/config/migration_integration_test.go @@ -72,7 +72,11 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) { // CRITICAL: Verify that user's settings are preserved // This was the bug - these settings were lost when Workspace was empty if cfg.Agents.Defaults.Provider != "openai" { - t.Errorf("Provider = %q, want %q (user's setting should be preserved)", cfg.Agents.Defaults.Provider, "openai") + t.Errorf( + "Provider = %q, want %q (user's setting should be preserved)", + cfg.Agents.Defaults.Provider, + "openai", + ) } // Old "model" field is migrated to "model_name" field if cfg.Agents.Defaults.ModelName != "gpt-4o" { @@ -299,7 +303,11 @@ func TestMigration_Integration_PreservesAllAgentsFields(t *testing.T) { t.Errorf("Agent.ID = %q, want %q", cfg.Agents.List[0].ID, "special-agent") } if cfg.Agents.List[0].Workspace != "/special/workspace" { - t.Errorf("Agent.Workspace = %q, want %q", cfg.Agents.List[0].Workspace, "/special/workspace") + t.Errorf( + "Agent.Workspace = %q, want %q", + cfg.Agents.List[0].Workspace, + "/special/workspace", + ) } // Workspace should have default since it was empty in legacy config @@ -362,7 +370,10 @@ func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) { // OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 { - t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(cfg.Channels.OneBot.GroupTrigger.Prefixes)) + t.Errorf( + "len(OneBot.GroupTrigger.Prefixes) = %d, want 2", + len(cfg.Channels.OneBot.GroupTrigger.Prefixes), + ) } else { if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" { t.Errorf("Prefixes[0] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[0], "/") @@ -443,13 +454,25 @@ func TestMigration_Integration_RoundTrip_SerializeAndLoad(t *testing.T) { // Verify configs are identical if cfg2.Agents.Defaults.Provider != cfg1.Agents.Defaults.Provider { - t.Errorf("Provider changed from %q to %q", cfg1.Agents.Defaults.Provider, cfg2.Agents.Defaults.Provider) + t.Errorf( + "Provider changed from %q to %q", + cfg1.Agents.Defaults.Provider, + cfg2.Agents.Defaults.Provider, + ) } if cfg2.Agents.Defaults.ModelName != cfg1.Agents.Defaults.ModelName { - t.Errorf("ModelName changed from %q to %q", cfg1.Agents.Defaults.ModelName, cfg2.Agents.Defaults.ModelName) + t.Errorf( + "ModelName changed from %q to %q", + cfg1.Agents.Defaults.ModelName, + cfg2.Agents.Defaults.ModelName, + ) } if cfg2.Agents.Defaults.MaxTokens != cfg1.Agents.Defaults.MaxTokens { - t.Errorf("MaxTokens changed from %d to %d", cfg1.Agents.Defaults.MaxTokens, cfg2.Agents.Defaults.MaxTokens) + t.Errorf( + "MaxTokens changed from %d to %d", + cfg1.Agents.Defaults.MaxTokens, + cfg2.Agents.Defaults.MaxTokens, + ) } } @@ -557,7 +580,11 @@ func TestMigration_Integration_ModelNameField(t *testing.T) { // GetModelName() should return model_name, not model (deprecated) if cfg.Agents.Defaults.GetModelName() != "deepseek-reasoner" { - t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "deepseek-reasoner") + t.Errorf( + "GetModelName() = %q, want %q", + cfg.Agents.Defaults.GetModelName(), + "deepseek-reasoner", + ) } if len(cfg.Agents.Defaults.ModelFallbacks) != 1 { diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index aeabe9730..1ae3c7b71 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -91,9 +91,11 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { func TestConvertProvidersToModelList_Multiple(t *testing.T) { cfg := &configV0{ Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, - Groq: providerConfigV0{APIKey: "groq-key"}, - Zhipu: providerConfigV0{APIKey: "zhipu-key"}, + OpenAI: openAIProviderConfigV0{ + providerConfigV0: providerConfigV0{APIKey: "openai-key"}, + }, + Groq: providerConfigV0{APIKey: "groq-key"}, + Zhipu: providerConfigV0{APIKey: "zhipu-key"}, }, } @@ -142,8 +144,13 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { // Other providers have no configuration, so they won't be converted. cfg := &configV0{ Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}}, - LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, + OpenAI: openAIProviderConfigV0{ + providerConfigV0: providerConfigV0{APIKey: "key1"}, + }, + LiteLLM: providerConfigV0{ + APIKey: "key-litellm", + APIBase: "http://localhost:4000/v1", + }, Anthropic: providerConfigV0{APIKey: "key2"}, OpenRouter: providerConfigV0{APIKey: "key3"}, Groq: providerConfigV0{APIKey: "key4"}, @@ -261,7 +268,11 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) { // Should use user's model, not default if result[0].Model != "deepseek/deepseek-reasoner" { - t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner") + t.Errorf( + "Model = %q, want %q (user's configured model)", + result[0].Model, + "deepseek/deepseek-reasoner", + ) } } @@ -371,7 +382,9 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes }, }, Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}}, + OpenAI: openAIProviderConfigV0{ + providerConfigV0: providerConfigV0{APIKey: "sk-openai"}, + }, DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, }, } @@ -391,7 +404,11 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes } case "deepseek": if mc.Model != "deepseek/deepseek-reasoner" { - t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner") + t.Errorf( + "DeepSeek Model = %q, want %q (user's)", + mc.Model, + "deepseek/deepseek-reasoner", + ) } } } @@ -489,7 +506,11 @@ func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T // ModelName should be the user's model value for backward compatibility if result[0].ModelName != "glm-4.7" { - t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7") + t.Errorf( + "ModelName = %q, want %q (user's model for backward compatibility)", + result[0].ModelName, + "glm-4.7", + ) } // Model should use the user's model with protocol prefix @@ -510,8 +531,10 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin }, }, Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, - Zhipu: providerConfigV0{APIKey: "zhipu-key"}, + OpenAI: openAIProviderConfigV0{ + providerConfigV0: providerConfigV0{APIKey: "openai-key"}, + }, + Zhipu: providerConfigV0{APIKey: "zhipu-key"}, }, } @@ -571,7 +594,11 @@ func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) { result := buildModelWithProtocol("openrouter", "openrouter/auto") if result != "openrouter/auto" { - t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto") + t.Errorf( + "buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", + result, + "openrouter/auto", + ) } } @@ -613,6 +640,10 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) // Model should NOT have duplicated prefix if result[0].Model != "openrouter/auto" { - t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto") + t.Errorf( + "Model = %q, want %q (should not duplicate prefix)", + result[0].Model, + "openrouter/auto", + ) } } diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index 6e88f4783..f001885af 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -17,7 +17,11 @@ func TestGetModelConfig_Found(t *testing.T) { Version: CurrentVersion, ModelList: []*ModelConfig{ {ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")}, - {ModelName: "other-model", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("key2")}, + { + ModelName: "other-model", + Model: "anthropic/claude", + APIKeys: SimpleSecureStrings("key2"), + }, }, } @@ -114,8 +118,16 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { func TestGetModelConfig_Concurrent(t *testing.T) { cfg := &Config{ ModelList: []*ModelConfig{ - {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, - {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, + { + ModelName: "concurrent-model", + Model: "openai/gpt-4o-1", + APIKeys: SimpleSecureStrings("key1"), + }, + { + ModelName: "concurrent-model", + Model: "openai/gpt-4o-2", + APIKeys: SimpleSecureStrings("key2"), + }, }, } @@ -290,7 +302,11 @@ func TestConfig_ValidateModelList(t *testing.T) { } if err != nil && tt.errMsg != "" { if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("ValidateModelList() error = %v, want error containing %q", err, tt.errMsg) + t.Errorf( + "ValidateModelList() error = %v, want error containing %q", + err, + tt.errMsg, + ) } } }) diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index e58c6dc9e..28fd9ff7d 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -117,7 +117,10 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { ModelName: "gpt-4", Model: "openai/gpt-4o", } - modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing + modelCfg.APIKeys = SimpleSecureStrings( + "key0", + "key1", + ) // Use internal field for multi-key testing modelCfg.Fallbacks = []string{"claude-3"} models := []*ModelConfig{modelCfg} @@ -196,7 +199,10 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { RequestTimeout: 30, ThinkingLevel: "high", } - modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing + modelCfg.APIKeys = SimpleSecureStrings( + "key0", + "key1", + ) // Use internal field for multi-key testing models := []*ModelConfig{modelCfg} result := expandMultiKeyModels(models) diff --git a/pkg/config/security.go b/pkg/config/security.go index 79dd26e14..c31e877c2 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -304,11 +304,13 @@ func (s *SecureString) UnmarshalJSON(value []byte) error { func (s SecureString) MarshalYAML() (any, error) { // Preserve raw value if it is already a reference (enc:// or file://) - if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) { + if strings.HasPrefix(s.raw, credential.EncScheme) || + strings.HasPrefix(s.raw, credential.FileScheme) { return s.raw, nil } // If resolved is a reference format (e.g. set via Set), copy back to raw - if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) { + if strings.HasPrefix(s.resolved, credential.EncScheme) || + strings.HasPrefix(s.resolved, credential.FileScheme) { s.raw = s.resolved return s.raw, nil } diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 6ca8637f4..5f0b61970 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -35,7 +35,10 @@ func TestJSONUnmarshalPrivateFields(t *testing.T) { t.Errorf("PublicField = %q, want 'pub'", s.PublicField) } if s.privateField != "" { - t.Errorf("privateField = %q, want empty because unexported fields are ignored", s.privateField) + t.Errorf( + "privateField = %q, want empty because unexported fields are ignored", + s.privateField, + ) } } @@ -352,13 +355,21 @@ skills: // Verify Channel tokens via Key() methods // Telegram - assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token.String()) + assert.Equal( + t, + "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + cfg.Channels.Telegram.Token.String(), + ) t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String()) // Feishu assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String()) assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey.String()) - assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken.String()) + assert.Equal( + t, + "feishu_test_verification_token", + cfg.Channels.Feishu.VerificationToken.String(), + ) t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String()) t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String()) t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String()) @@ -383,7 +394,11 @@ skills: // LINE assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String()) - assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken.String()) + assert.Equal( + t, + "line_test_channel_access_token", + cfg.Channels.LINE.ChannelAccessToken.String(), + ) t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String()) t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.String()) @@ -431,7 +446,11 @@ skills: assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token.String()) t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token.String()) - assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String()) + assert.Equal( + t, + "clawhub-auth-token-from-file", + cfg.Tools.Skills.Registries.ClawHub.AuthToken.String(), + ) t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String()) t.Log("All security keys are successfully accessible via their respective Key() methods") diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 60d9d5e5a..e205d7cf3 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -15,7 +15,10 @@ import ( // JobExecutor is the interface for executing cron jobs through the agent type JobExecutor interface { - ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) + ProcessDirectWithChannel( + ctx context.Context, + content, sessionKey, channel, chatID string, + ) (string, error) // PublishResponseIfNeeded sends response to the outbound bus only when the // agent did not already deliver content through the message tool in this round. PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) @@ -34,8 +37,13 @@ type CronTool struct { // NewCronTool creates a new CronTool // execTimeout: 0 means no timeout, >0 sets the timeout duration func NewCronTool( - cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, - execTimeout time.Duration, config *config.Config, + cronService *cron.CronService, + executor JobExecutor, + msgBus *bus.MessageBus, + workspace string, + restrict bool, + execTimeout time.Duration, + config *config.Config, ) (*CronTool, error) { allowCommand := true execEnabled := true @@ -156,7 +164,9 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult chatID := ToolChatID(ctx) if channel == "" || chatID == "" { - return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.") + return ErrorResult( + "no session context (channel/chat_id not set). Use this tool in an active conversation.", + ) } message, ok := args["message"].(string) @@ -208,7 +218,9 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult // Validate type parameter (server-side whitelist, not just LLM schema hint) msgType, _ := args["type"].(string) if msgType != "" && msgType != "message" && msgType != "directive" { - return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType)) + return ErrorResult( + fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType), + ) } // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index 186c6a75e..251db5803 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -49,7 +49,11 @@ func (s *stubJobExecutor) PublishResponseIfNeeded( s.publishedChatID = chatID } -func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { +func newTestCronToolWithExecutorAndConfig( + t *testing.T, + executor JobExecutor, + cfg *config.Config, +) *CronTool { t.Helper() storePath := filepath.Join(t.TempDir(), "cron.json") cronService := cron.NewCronService(storePath, nil) @@ -102,7 +106,10 @@ func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) { }) if result.IsError { - t.Fatalf("expected command scheduling without confirm to succeed by default, got: %s", result.ForLLM) + t.Fatalf( + "expected command scheduling without confirm to succeed by default, got: %s", + result.ForLLM, + ) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) @@ -190,7 +197,10 @@ func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) { }) if result.IsError { - t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM) + t.Fatalf( + "expected command scheduling to succeed from internal channel, got: %s", + result.ForLLM, + ) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) @@ -225,7 +235,10 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { }) if result.IsError { - t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM) + t.Fatalf( + "expected non-command reminder to succeed from remote channel, got: %s", + result.ForLLM, + ) } } @@ -297,7 +310,11 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey) } if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" { - t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID) + t.Fatalf( + "executor target = %s/%s, want telegram/chat-1", + executor.lastChan, + executor.lastChatID, + ) } if executor.lastPrompt != "send me a poem" { t.Fatalf("prompt = %q, want original message", executor.lastPrompt) @@ -306,7 +323,11 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { t.Fatalf("published response = %q, want generated reply", executor.publishedResp) } if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { - t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID) + t.Fatalf( + "published target = %s/%s, want telegram/chat-1", + executor.publishedChan, + executor.publishedChatID, + ) } } @@ -342,7 +363,10 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) { } if executor.publishedResp != "" { - t.Fatalf("expected no published response when message tool already sent, got: %q", executor.publishedResp) + t.Fatalf( + "expected no published response when message tool already sent, got: %q", + executor.publishedResp, + ) } } @@ -386,7 +410,9 @@ func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) { } if executor.lastPrompt == "" { - t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked") + t.Fatal( + "expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked", + ) } if executor.publishedResp != "agent processed" { t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed") diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index d5bebf4a2..78fc512c6 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -16,7 +16,11 @@ type EditFileTool struct { } // NewEditFileTool creates a new EditFileTool with optional directory restriction. -func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool { +func NewEditFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *EditFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -79,7 +83,11 @@ type AppendFileTool struct { fs fileSystem } -func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool { +func NewAppendFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *AppendFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -166,7 +174,10 @@ func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) count := strings.Count(contentStr, oldText) if count > 1 { - return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) + return nil, fmt.Errorf( + "old_text appears %d times. Please provide more context to make it unique", + count, + ) } newContent := strings.Replace(contentStr, oldText, newText, 1) diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go index 83a7e778c..25f89fb88 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/edit_test.go @@ -76,7 +76,8 @@ func TestEditTool_EditFile_NotFound(t *testing.T) { } // Should mention file not found - if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { + if !strings.Contains(result.ForLLM, "not found") && + !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM) } } @@ -103,7 +104,8 @@ func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { } // Should mention old_text not found - if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { + if !strings.Contains(result.ForLLM, "not found") && + !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM) } } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 39d45013d..35da9ecde 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -20,7 +20,11 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow -func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) { +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { if workspace == "" { return path, fmt.Errorf("workspace is not defined") } @@ -483,7 +487,11 @@ type WriteFileTool struct { fs fileSystem } -func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -536,7 +544,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR if !overwrite { if _, err := t.fs.Open(path); err == nil { - return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path)) + return ErrorResult( + fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path), + ) } } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 0b4dd310b..90b20b47e 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + if !strings.Contains(result.ForLLM, "failed to open file") && + !strings.Contains(result.ForUser, "failed to read") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { } // Should mention required parameter - if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { + if !strings.Contains(result.ForLLM, "path is required") && + !strings.Contains(result.ForUser, "path is required") { t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) } } @@ -297,7 +303,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) { "content": "replaced in sandbox", "overwrite": true, }) - assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM) + assert.False( + t, + result.IsError, + "expected success in sandbox mode with overwrite=true, got: %s", + result.ForLLM, + ) data, err := os.ReadFile(filepath.Join(workspace, testFile)) assert.NoError(t, err) @@ -325,7 +336,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { } // Should list files and directories - if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { + if !strings.Contains(result.ForLLM, "file1.txt") || + !strings.Contains(result.ForLLM, "file2.txt") { t.Errorf("Expected files in listing, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subdir") { @@ -349,8 +361,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + if !strings.Contains(result.ForLLM, "failed to read") && + !strings.Contains(result.ForUser, "failed to read") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -397,7 +414,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { // os.Root might return different errors depending on platform/implementation // but it definitely should error. // Our wrapper returns "access denied or file not found" - if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && + if !strings.Contains(result.ForLLM, "access denied") && + !strings.Contains(result.ForLLM, "file not found") && !strings.Contains(result.ForLLM, "no such file") { t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) } @@ -416,10 +434,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { }) // We EXPECT IsError=true (access blocked due to empty workspace) - assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) + assert.True( + t, + result.IsError, + "Security Regression: Empty workspace allowed access! content: %s", + result.ForLLM, + ) // Verify it failed for the right reason - assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") + assert.Contains( + t, + result.ForLLM, + "workspace is not defined", + "Expected 'workspace is not defined' error", + ) } // TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: @@ -653,7 +681,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) - result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")}) + result := tool.Execute( + context.Background(), + map[string]any{"path": filepath.Join(linkPath, "secret.txt")}, + ) if !result.IsError { t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) } diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go index 779b1d5a7..e3d5c152c 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/i2c.go @@ -65,7 +65,9 @@ func (t *I2CTool) Parameters() map[string]any { func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { - return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.") + return ErrorResult( + "I2C is only supported on Linux. This tool requires /dev/i2c-* device files.", + ) } action, ok := args["action"].(string) @@ -83,7 +85,9 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult case "write": return t.writeDevice(args) default: - return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) + return ErrorResult( + fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action), + ) } } diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index 4eaaf8f09..ccd57b24b 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -55,7 +55,12 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool { size: i2cSmbusQuick, data: nil, } - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + i2cSmbus, + uintptr(unsafe.Pointer(&args)), + ) return errno == 0 } @@ -67,7 +72,12 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool { size: i2cSmbusByte, data: &data, } - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + i2cSmbus, + uintptr(unsafe.Pointer(&args)), + ) return errno == 0 } @@ -83,16 +93,29 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { devPath := fmt.Sprintf("/dev/i2c-%s", bus) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { - return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err)) + return ErrorResult( + fmt.Sprintf( + "failed to open %s: %v (check permissions and i2c-dev module)", + devPath, + err, + ), + ) } defer syscall.Close(fd) // Query adapter capabilities to determine available probe methods. // I2C_FUNCS writes an unsigned long, which is word-sized on Linux. var funcs uintptr - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs))) + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + i2cFuncs, + uintptr(unsafe.Pointer(&funcs)), + ) if errno != 0 { - return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno)) + return ErrorResult( + fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno), + ) } hasQuick := funcs&i2cFuncSmbusQuick != 0 @@ -100,7 +123,10 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { if !hasQuick && !hasReadByte { return ErrorResult( - fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath), + fmt.Sprintf( + "I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", + devPath, + ), ) } @@ -132,7 +158,9 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { } if len(found) == 0 { - return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) + return SilentResult( + fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath), + ) } result, _ := json.MarshalIndent(map[string]any{ diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index 5bffb4e89..d4674d376 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -314,7 +314,10 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont return result } -func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) { +func (t *MCPTool) storeEmbeddedResource( + ctx context.Context, + content *mcp.EmbeddedResource, +) (string, string) { if content == nil || content.Resource == nil { return "", "[MCP returned an embedded resource without data.]" } @@ -374,23 +377,39 @@ func (t *MCPTool) storeBinaryContent( dir := media.TempDir() if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + return "", fmt.Sprintf( + "[MCP returned %s content (%s) but it could not be stored.]", + kind, + mimeType, + ) } ext := extensionForMIMEType(mimeType) tmpFile, err := os.CreateTemp(dir, "mcp-*"+ext) if err != nil { - return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + return "", fmt.Sprintf( + "[MCP returned %s content (%s) but it could not be stored.]", + kind, + mimeType, + ) } tmpPath := tmpFile.Name() if _, err = tmpFile.Write(data); err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) - return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + return "", fmt.Sprintf( + "[MCP returned %s content (%s) but it could not be stored.]", + kind, + mimeType, + ) } if err = tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) - return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) + return "", fmt.Sprintf( + "[MCP returned %s content (%s) but it could not be stored.]", + kind, + mimeType, + ) } scope := fmt.Sprintf( @@ -470,7 +489,10 @@ func summarizeEmbeddedResource(content *mcp.EmbeddedResource) string { normalizedMIMEType(resource.MIMEType), ) } - return fmt.Sprintf("[MCP returned embedded resource (%s).]", normalizedMIMEType(resource.MIMEType)) + return fmt.Sprintf( + "[MCP returned embedded resource (%s).]", + normalizedMIMEType(resource.MIMEType), + ) } func annotationsAllowUser(annotations *mcp.Annotations) bool { diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index 8bbac3bc7..3b514cd82 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -571,7 +571,10 @@ func TestMCPTool_Execute_EmbeddedResourceBlobStoredAsMedia(t *testing.T) { result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil) if len(result.Media) != 1 { - t.Fatalf("expected embedded resource blob to be stored as media, got %d refs", len(result.Media)) + t.Fatalf( + "expected embedded resource blob to be stored as media, got %d refs", + len(result.Media), + ) } path, _, err := store.ResolveWithMeta(result.Media[0]) if err != nil { diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 05630972e..1b8bfab4a 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -43,7 +43,10 @@ func TestMessageTool_Execute_Success(t *testing.T) { // - ForLLM contains send status description if result.ForLLM != "Message sent to test-channel:test-chat-id" { - t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM) + t.Errorf( + "Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", + result.ForLLM, + ) } // - ForUser is empty (user already received message directly) @@ -88,7 +91,10 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { t.Error("Expected Silent=true") } if result.ForLLM != "Message sent to custom-channel:custom-chat-id" { - t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM) + t.Errorf( + "Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", + result.ForLLM, + ) } } diff --git a/pkg/tools/normalization.go b/pkg/tools/normalization.go index 3a76c5d92..9cd9c65c6 100644 --- a/pkg/tools/normalization.go +++ b/pkg/tools/normalization.go @@ -215,28 +215,43 @@ func storeInlineDataURL( payload = strings.NewReplacer("\n", "", "\r", "", "\t", "", " ", "").Replace(payload) decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { - return "", fmt.Sprintf("[Tool returned inline media content (%s) that could not be decoded.]", mimeType) + return "", fmt.Sprintf( + "[Tool returned inline media content (%s) that could not be decoded.]", + mimeType, + ) } dir := media.TempDir() if err = os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + return "", fmt.Sprintf( + "[Tool returned inline media content (%s) but it could not be stored.]", + mimeType, + ) } ext := extensionForMIMEType(mimeType) tmpFile, err := os.CreateTemp(dir, "tool-inline-*"+ext) if err != nil { - return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + return "", fmt.Sprintf( + "[Tool returned inline media content (%s) but it could not be stored.]", + mimeType, + ) } tmpPath := tmpFile.Name() if _, err = tmpFile.Write(decoded); err != nil { tmpFile.Close() _ = os.Remove(tmpPath) - return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + return "", fmt.Sprintf( + "[Tool returned inline media content (%s) but it could not be stored.]", + mimeType, + ) } if err = tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) - return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) + return "", fmt.Sprintf( + "[Tool returned inline media content (%s) but it could not be stored.]", + mimeType, + ) } filename := sanitizeIdentifierComponent(toolName) + ext @@ -255,7 +270,10 @@ func storeInlineDataURL( }, scope) if err != nil { _ = os.Remove(tmpPath) - return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be registered.]", mimeType) + return "", fmt.Sprintf( + "[Tool returned inline media content (%s) but it could not be registered.]", + mimeType, + ) } return ref, fmt.Sprintf(inlineMediaStoredMessage, mimeType) diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 56af8d695..e16be0ccb 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" "sync" "sync/atomic" "time" @@ -24,6 +25,7 @@ type ToolRegistry struct { mu sync.RWMutex version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation mediaStore media.MediaStore + allowlist map[string]struct{} } type mediaStoreAware interface { @@ -36,10 +38,40 @@ func NewToolRegistry() *ToolRegistry { } } +// SetAllowlist restricts registrations to the provided runtime tool names. +// A nil slice means "allow all". An empty-but-non-nil slice means "allow none". +func (r *ToolRegistry) SetAllowlist(names []string) { + r.mu.Lock() + defer r.mu.Unlock() + + if names == nil { + r.allowlist = nil + return + } + + allowlist := make(map[string]struct{}, len(names)) + for _, name := range names { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + allowlist[trimmed] = struct{}{} + } + r.allowlist = allowlist +} + func (r *ToolRegistry) Register(tool Tool) { r.mu.Lock() defer r.mu.Unlock() name := tool.Name() + if !r.toolAllowedLocked(name) { + logger.DebugCF( + "tools", + "Skipped core tool registration by agent allowlist", + map[string]any{"name": name}, + ) + return + } if _, exists := r.tools[name]; exists { logger.WarnCF("tools", "Tool registration overwrites existing tool", map[string]any{"name": name}) @@ -61,6 +93,14 @@ func (r *ToolRegistry) RegisterHidden(tool Tool) { r.mu.Lock() defer r.mu.Unlock() name := tool.Name() + if !r.toolAllowedLocked(name) { + logger.DebugCF( + "tools", + "Skipped hidden tool registration by agent allowlist", + map[string]any{"name": name}, + ) + return + } if _, exists := r.tools[name]; exists { logger.WarnCF("tools", "Hidden tool registration overwrites existing tool", map[string]any{"name": name}) @@ -128,6 +168,14 @@ func (r *ToolRegistry) Version() uint64 { return r.version.Load() } +func (r *ToolRegistry) toolAllowedLocked(name string) bool { + if r.allowlist == nil { + return true + } + _, ok := r.allowlist[name] + return ok +} + // HiddenToolSnapshot holds a consistent snapshot of hidden tools and the // registry version at which it was taken. Used by BM25SearchTool cache. type HiddenToolSnapshot struct { @@ -203,7 +251,9 @@ func (r *ToolRegistry) ExecuteWithContext( map[string]any{ "tool": name, }) - return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) + return ErrorResult( + fmt.Sprintf("tool %q not found", name), + ).WithError(fmt.Errorf("tool not found")) } // Validate arguments against the tool's declared schema. @@ -385,6 +435,12 @@ func (r *ToolRegistry) Clone() *ToolRegistry { tools: make(map[string]*ToolEntry, len(r.tools)), mediaStore: r.mediaStore, } + if r.allowlist != nil { + clone.allowlist = make(map[string]struct{}, len(r.allowlist)) + for name := range r.allowlist { + clone.allowlist[name] = struct{}{} + } + } for name, entry := range r.tools { clone.tools[name] = &ToolEntry{ Tool: entry.Tool, @@ -417,7 +473,10 @@ func (r *ToolRegistry) GetSummaries() []string { continue } - summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description())) + summaries = append( + summaries, + fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description()), + ) } return summaries } diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index db52749f6..17b3cd127 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -44,7 +44,11 @@ type mockAsyncRegistryTool struct { lastCB AsyncCallback } -func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult { +func (m *mockAsyncRegistryTool) ExecuteAsync( + _ context.Context, + args map[string]any, + cb AsyncCallback, +) *ToolResult { m.lastCB = cb return m.result } @@ -95,6 +99,28 @@ func TestToolRegistry_RegisterAndGet(t *testing.T) { } } +func TestToolRegistry_AllowlistFiltersRegistrations(t *testing.T) { + r := NewToolRegistry() + r.SetAllowlist([]string{"allowed_tool"}) + + r.Register(newMockTool("allowed_tool", "allowed")) + r.Register(newMockTool("blocked_tool", "blocked")) + r.RegisterHidden(newMockTool("hidden_blocked", "hidden blocked")) + + if _, ok := r.Get("allowed_tool"); !ok { + t.Fatal("expected allowed_tool to be registered") + } + if _, ok := r.Get("blocked_tool"); ok { + t.Fatal("blocked_tool should not be registered") + } + if _, ok := r.Get("hidden_blocked"); ok { + t.Fatal("hidden_blocked should not be registered") + } + if got := r.List(); len(got) != 1 || got[0] != "allowed_tool" { + t.Fatalf("registry list = %v, want [allowed_tool]", got) + } +} + func TestToolRegistry_Get_NotFound(t *testing.T) { r := NewToolRegistry() _, ok := r.Get("nonexistent") @@ -269,7 +295,11 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) { t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name) } if got.Function.Description != want.Function.Description { - t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description) + t.Errorf( + "Description: want %q, got %q", + want.Function.Description, + got.Function.Description, + ) } } @@ -372,7 +402,10 @@ func TestToolRegistry_Clone(t *testing.T) { t.Errorf("expected parent to have 4 tools, got %d", r.Count()) } if clone.Count() != 3 { - t.Errorf("expected clone to still have 3 tools after parent mutation, got %d", clone.Count()) + t.Errorf( + "expected clone to still have 3 tools after parent mutation, got %d", + clone.Count(), + ) } if _, ok := clone.Get("spawn"); ok { t.Error("expected clone NOT to have 'spawn' tool registered on parent after cloning") @@ -668,7 +701,14 @@ func TestToolRegistry_ExecuteWithContext_SanitizesLargeBase64Payload(t *testing. result: SilentResult(payload), }) - result := r.ExecuteWithContext(context.Background(), "base64_tool", nil, "telegram", "chat-1", nil) + result := r.ExecuteWithContext( + context.Background(), + "base64_tool", + nil, + "telegram", + "chat-1", + nil, + ) if result.ForLLM != largeBase64OmittedMessage { t.Fatalf("expected sanitized payload, got %q", result.ForLLM) @@ -688,7 +728,14 @@ func TestToolRegistry_ExecuteWithContext_ExtractsInlineMediaDataURL(t *testing.T result: SilentResult(payload), }) - result := r.ExecuteWithContext(context.Background(), "inline_media_tool", nil, "telegram", "chat-42", nil) + result := r.ExecuteWithContext( + context.Background(), + "inline_media_tool", + nil, + "telegram", + "chat-42", + nil, + ) if len(result.Media) != 1 { t.Fatalf("expected 1 media ref, got %d", len(result.Media)) @@ -723,7 +770,14 @@ func TestToolRegistry_ExecuteWithContext_SanitizesInlineMediaWithoutStore(t *tes result: SilentResult(payload), }) - result := r.ExecuteWithContext(context.Background(), "inline_media_no_store", nil, "telegram", "chat-42", nil) + result := r.ExecuteWithContext( + context.Background(), + "inline_media_no_store", + nil, + "telegram", + "chat-42", + nil, + ) if strings.Contains(result.ForLLM, "data:image/png;base64") { t.Fatalf("expected inline data URL to be removed from ForLLM, got %q", result.ForLLM) diff --git a/pkg/tools/result.go b/pkg/tools/result.go index c81213125..1976eb88b 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/result.go @@ -80,7 +80,10 @@ func (tr *ToolResult) ContentForLLM() string { } } if len(tr.ArtifactTags) > 0 { - artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote + artifactNote := "Local artifact paths: " + strings.Join( + tr.ArtifactTags, + " ", + ) + "\n" + artifactPathsLLMNote if content == "" { content = artifactNote } else if !strings.Contains(content, artifactNote) { diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go index 5f08cb4fa..87b2f1b4b 100644 --- a/pkg/tools/result_test.go +++ b/pkg/tools/result_test.go @@ -142,7 +142,11 @@ func TestToolResultJSONSerialization(t *testing.T) { t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM) } if decoded.ForUser != tt.result.ForUser { - t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser) + t.Errorf( + "ForUser mismatch: got '%s', want '%s'", + decoded.ForUser, + tt.result.ForUser, + ) } if decoded.Silent != tt.result.Silent { t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent) diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go index f41c80d90..21326504d 100644 --- a/pkg/tools/search_tool.go +++ b/pkg/tools/search_tool.go @@ -56,19 +56,38 @@ func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *Too } if len(pattern) > MaxRegexPatternLength { - logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)}) - return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength)) + logger.WarnCF( + "discovery", + "Regex pattern rejected (too long)", + map[string]any{"len": len(pattern)}, + ) + return ErrorResult( + fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength), + ) } logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern}) res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) if err != nil { - logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()}) - return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) + logger.WarnCF( + "discovery", + "Invalid regex pattern", + map[string]any{"pattern": pattern, "error": err.Error()}, + ) + return ErrorResult( + fmt.Sprintf( + "Invalid regex pattern syntax: %v. Please fix your regex and try again.", + err, + ), + ) } - logger.InfoCF("discovery", "Regex search completed", map[string]any{"pattern": pattern, "results": len(res)}) + logger.InfoCF( + "discovery", + "Regex search completed", + map[string]any{"pattern": pattern, "results": len(res)}, + ) return formatDiscoveryResponse(t.registry, res, t.ttl) } @@ -138,7 +157,11 @@ func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *Tool } } - logger.InfoCF("discovery", "BM25 search completed", map[string]any{"query": query, "results": len(results)}) + logger.InfoCF( + "discovery", + "BM25 search completed", + map[string]any{"query": query, "results": len(results)}, + ) return formatDiscoveryResponse(t.registry, results, t.ttl) } @@ -150,7 +173,10 @@ type ToolSearchResult struct { Description string `json:"description"` } -func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) { +func (r *ToolRegistry) SearchRegex( + pattern string, + maxSearchResults int, +) ([]ToolSearchResult, error) { if maxSearchResults <= 0 { return nil, nil } @@ -188,7 +214,11 @@ func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]Tool return results, nil } -func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult { +func formatDiscoveryResponse( + registry *ToolRegistry, + results []ToolSearchResult, + ttl int, +) *ToolResult { if len(results) == 0 { return SilentResult("No tools found matching the query.") } @@ -274,7 +304,11 @@ func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine { cached := &bm25CachedEngine{engine: buildBM25Engine(docs)} t.cachedEngine = cached t.cacheVersion = snap.Version - logger.DebugCF("discovery", "BM25 engine rebuilt", map[string]any{"docs": len(docs), "version": snap.Version}) + logger.DebugCF( + "discovery", + "BM25 engine rebuilt", + map[string]any{"docs": len(docs), "version": snap.Version}, + ) return cached } diff --git a/pkg/tools/search_tools_test.go b/pkg/tools/search_tools_test.go index 3aae941cb..72cb11444 100644 --- a/pkg/tools/search_tools_test.go +++ b/pkg/tools/search_tools_test.go @@ -93,7 +93,10 @@ func TestRegexSearchTool_Execute(t *testing.T) { reg.mu.RLock() defer reg.mu.RUnlock() if reg.tools["mcp_read_file"].TTL != 5 { - t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", reg.tools["mcp_read_file"].TTL) + t.Errorf( + "Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", + reg.tools["mcp_read_file"].TTL, + ) } if reg.tools["mcp_fetch_net"].TTL != 0 { t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)") diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go index 44198381e..a344f4b5c 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/send_file.go @@ -142,7 +142,10 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult(fmt.Sprintf("failed to register media: %v", err)) } - return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}).WithResponseHandled() + return MediaResult( + fmt.Sprintf("File %q sent to user", filename), + []string{ref}, + ).WithResponseHandled() } // detectMediaType determines the MIME type of a file. diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go index f36baf7d0..26b3c17ab 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/send_file_test.go @@ -79,7 +79,11 @@ func TestSendFileTool_FileTooLarge(t *testing.T) { func TestSendFileTool_DefaultMaxSize(t *testing.T) { tool := NewSendFileTool("/tmp", false, 0, nil) if tool.maxFileSize != config.DefaultMaxMediaSize { - t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize) + t.Errorf( + "expected default max size %d, got %d", + config.DefaultMaxMediaSize, + tool.maxFileSize, + ) } } @@ -162,7 +166,11 @@ func TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) { t.Cleanup(func() { _ = os.Remove(testPath) }) pattern := regexp.MustCompile( - "^" + regexp.QuoteMeta(filepath.Clean(mediaDir)) + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", + "^" + regexp.QuoteMeta( + filepath.Clean(mediaDir), + ) + "(?:" + regexp.QuoteMeta( + string(os.PathSeparator), + ) + "|$)", ) store := media.NewFileMediaStore() diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 6ee1cb993..0d1c4c5db 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -113,7 +113,11 @@ var ( } ) -func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) { +func NewExecTool( + workingDir string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) (*ExecTool, error) { return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...) } @@ -193,8 +197,16 @@ func (t *ExecTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "action": map[string]any{ - "type": "string", - "enum": []string{"run", "list", "poll", "read", "write", "kill", "send-keys"}, + "type": "string", + "enum": []string{ + "run", + "list", + "poll", + "read", + "write", + "kill", + "send-keys", + }, "description": "Action: run (execute command), list (show sessions), poll (check status), read (get output), write (send input), kill (terminate), send-keys (send keys to PTY)", }, "command": map[string]any{ @@ -300,7 +312,12 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes cwd := t.workingDir if wd, ok := args["cwd"].(string); ok && wd != "" { if t.restrictToWorkspace && t.workingDir != "" { - resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns) + resolvedWD, err := validatePathWithAllowPaths( + wd, + t.workingDir, + true, + t.allowedPathPatterns, + ) if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } @@ -326,7 +343,9 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir { resolved, err := filepath.EvalSymlinks(cwd) if err != nil { - return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err)) + return ErrorResult( + fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err), + ) } if isAllowedPath(resolved, t.allowedPathPatterns) { cwd = resolved @@ -364,7 +383,14 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult var cmd *exec.Cmd if runtime.GOOS == "windows" { - cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) + cmd = exec.CommandContext( + cmdCtx, + "powershell", + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ) } else { cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) } @@ -442,7 +468,10 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult maxLen := 10000 if len(output) > maxLen { - output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) + output = output[:maxLen] + fmt.Sprintf( + "\n... (truncated, %d more chars)", + len(output)-maxLen, + ) } if err != nil { @@ -460,7 +489,11 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult } } -func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult { +func (t *ExecTool) runBackground( + ctx context.Context, + command, cwd string, + ptyEnabled bool, +) *ToolResult { sessionID := generateSessionID() session := &ProcessSession{ ID: sessionID, @@ -553,7 +586,8 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn n, err := session.ptyMaster.Read(buf) if n > 0 { raw := string(buf[:n]) - if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() { + if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && + mode != session.GetPtyKeyMode() { session.SetPtyKeyMode(mode) } @@ -734,12 +768,16 @@ func (t *ExecTool) executeWrite(args map[string]any) *ToolResult { } if session.IsDone() { - return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + return ErrorResult( + fmt.Sprintf("process already exited with code %d", session.GetExitCode()), + ) } if err := session.Write(data); err != nil { if errors.Is(err, ErrSessionDone) { - return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + return ErrorResult( + fmt.Sprintf("process already exited with code %d", session.GetExitCode()), + ) } return ErrorResult(fmt.Sprintf("failed to write to session: %v", err)) } @@ -770,7 +808,9 @@ func (t *ExecTool) executeKill(args map[string]any) *ToolResult { } if session.IsDone() { - return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + return ErrorResult( + fmt.Sprintf("process already exited with code %d", session.GetExitCode()), + ) } if err := session.Kill(); err != nil { @@ -992,12 +1032,16 @@ func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult { } if session.IsDone() { - return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + return ErrorResult( + fmt.Sprintf("process already exited with code %d", session.GetExitCode()), + ) } if err := session.Write(data); err != nil { if errors.Is(err, ErrSessionDone) { - return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) + return ErrorResult( + fmt.Sprintf("process already exited with code %d", session.GetExitCode()), + ) } return ErrorResult(fmt.Sprintf("failed to send keys: %v", err)) } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index a8de2f4c9..228ec1067 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -100,8 +100,13 @@ func TestShellTool_Timeout(t *testing.T) { } // Should mention timeout - if !strings.Contains(result.ForLLM, "timed out") && !strings.Contains(result.ForUser, "timed out") { - t.Errorf("Expected timeout message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + if !strings.Contains(result.ForLLM, "timed out") && + !strings.Contains(result.ForUser, "timed out") { + t.Errorf( + "Expected timeout message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -156,7 +161,11 @@ func TestShellTool_DangerousCommand(t *testing.T) { } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { - t.Errorf("Expected 'blocked' message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + t.Errorf( + "Expected 'blocked' message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -177,7 +186,11 @@ func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { t.Errorf("Expected kill command to be blocked") } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { - t.Errorf("Expected blocked message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + t.Errorf( + "Expected blocked message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -269,7 +282,10 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { }) if !result.IsError { - t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM) + t.Fatalf( + "expected working_dir outside workspace to be blocked, got output: %s", + result.ForLLM, + ) } if !strings.Contains(result.ForLLM, "blocked") { t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) @@ -444,7 +460,10 @@ func TestShellTool_DevNullAllowed(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + result := tool.Execute( + context.Background(), + map[string]any{"action": "run", "command": cmd}, + ) if result.IsError && strings.Contains(result.ForLLM, "blocked") { t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM) } @@ -473,7 +492,10 @@ func TestShellTool_BlockDevices(t *testing.T) { } for _, cmd := range blocked { - result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + result := tool.Execute( + context.Background(), + map[string]any{"action": "run", "command": cmd}, + ) if !result.IsError { t.Errorf("expected block device write to be blocked: %s", cmd) } @@ -497,9 +519,16 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + result := tool.Execute( + context.Background(), + map[string]any{"action": "run", "command": cmd}, + ) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { - t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) + t.Errorf( + "safe path should not be blocked by workspace check: %s\n error: %s", + cmd, + result.ForLLM, + ) } } } @@ -591,7 +620,10 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) { "command": "git push origin main", }) if result.IsError && strings.Contains(result.ForLLM, "blocked") { - t.Errorf("custom allow pattern should exempt 'git push origin main', got: %s", result.ForLLM) + t.Errorf( + "custom allow pattern should exempt 'git push origin main', got: %s", + result.ForLLM, + ) } // "git push upstream main" should still be blocked (does not match allow pattern). @@ -629,7 +661,11 @@ func TestShellTool_URLsNotBlocked(t *testing.T) { result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) cancel() if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { - t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) + t.Errorf( + "command with URL should not be blocked by workspace check: %s\n error: %s", + cmd, + result.ForLLM, + ) } } } @@ -652,7 +688,10 @@ func TestShellTool_FileURISandboxing(t *testing.T) { } for _, cmd := range blockedCommands { - result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + result := tool.Execute( + context.Background(), + map[string]any{"action": "run", "command": cmd}, + ) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("file:// URI outside workspace should be blocked: %s", cmd) } @@ -670,9 +709,16 @@ func TestShellTool_FileURISandboxing(t *testing.T) { } for _, cmd := range allowedCommands { - result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + result := tool.Execute( + context.Background(), + map[string]any{"action": "run", "command": cmd}, + ) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { - t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM) + t.Errorf( + "file:// URI inside workspace should be allowed: %s\n error: %s", + cmd, + result.ForLLM, + ) } } } @@ -696,7 +742,10 @@ func TestShellTool_URLBypassPrevented(t *testing.T) { } for _, cmd := range blockedCommands { - result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) + result := tool.Execute( + context.Background(), + map[string]any{"action": "run", "command": cmd}, + ) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM) } @@ -1221,7 +1270,9 @@ func TestShellTool_PTY_ProcessGroupKill(t *testing.T) { // The binary is created in /tmp/test_pgroup.c and compiled as part of test setup. testBinary := "/tmp/test_pgroup" if _, err := os.Stat(testBinary); os.IsNotExist(err) { - t.Skip("Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c") + t.Skip( + "Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c", + ) } tool, err := NewExecTool("", false) @@ -1555,8 +1606,16 @@ func TestDetectPtyKeyMode(t *testing.T) { {"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI}, {"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, {"both rmkx first", "\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, - {"multiple toggles smkx last", "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, - {"multiple toggles rmkx last", "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, + { + "multiple toggles smkx last", + "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", + PtyKeyModeSS3, + }, + { + "multiple toggles rmkx last", + "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", + PtyKeyModeCSI, + }, {"partial smkx", "\x1b[?1h", PtyKeyModeSS3}, {"partial rmkx", "\x1b[?1l", PtyKeyModeCSI}, } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 71bfe730b..ffb4b0c52 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -96,7 +96,11 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To if !force { if _, err := os.Stat(targetDir); err == nil { return ErrorResult( - fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), + fmt.Sprintf( + "skill %q already installed at %s. Use force=true to reinstall.", + slug, + targetDir, + ), ) } } else { @@ -142,7 +146,9 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "error": rmErr.Error(), }) } - return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) + return ErrorResult( + fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug), + ) } // Write origin metadata. @@ -162,7 +168,10 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To // Build result with moderation warning if suspicious. var output string if result.IsSuspicious { - output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) + output = fmt.Sprintf( + "⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", + slug, + ) } output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n", slug, result.Version, registry.Name(), targetDir) diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go index 2b6cffd38..8f7401dfa 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/skills_search.go @@ -17,7 +17,10 @@ type FindSkillsTool struct { // NewFindSkillsTool creates a new FindSkillsTool. // registryMgr is the shared registry manager (built from config in createToolRegistry). // cache is the search cache for deduplicating similar queries. -func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { +func NewFindSkillsTool( + registryMgr *skills.RegistryManager, + cache *skills.SearchCache, +) *FindSkillsTool { return &FindSkillsTool{ registryMgr: registryMgr, cache: cache, diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index 416fd2226..22202d899 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -77,10 +77,12 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too } // Restrict lookup to tasks that belong to this conversation. - if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel { + if callerChannel != "" && taskCopy.OriginChannel != "" && + taskCopy.OriginChannel != callerChannel { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } - if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID { + if callerChatID != "" && taskCopy.OriginChatID != "" && + taskCopy.OriginChatID != callerChatID { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index 9c772d61a..22b885fb7 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -195,7 +195,12 @@ func TestSpawnStatusTool_TaskID_NonString(t *testing.T) { for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} { result := tool.Execute(context.Background(), map[string]any{"task_id": badVal}) if !result.IsError { - t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM) + t.Errorf( + "Expected error for task_id=%T(%v), got success: %s", + badVal, + badVal, + result.ForLLM, + ) } if !strings.Contains(result.ForLLM, "task_id must be a string") { t.Errorf("Expected type-error message, got: %s", result.ForLLM) @@ -319,7 +324,10 @@ func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) { t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM) } if pos2 > pos10 { - t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM) + t.Errorf( + "Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", + result.ForLLM, + ) } } diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go index 0ca17e84f..cdf23db86 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/spi.go @@ -69,7 +69,9 @@ func (t *SPITool) Parameters() map[string]any { func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { - return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.") + return ErrorResult( + "SPI is only supported on Linux. This tool requires /dev/spidev* device files.", + ) } action, ok := args["action"].(string) @@ -124,7 +126,9 @@ func (t *SPITool) list() *ToolResult { // parseSPIArgs extracts and validates common SPI parameters // //nolint:unused // Used by spi_linux.go -func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { +func parseSPIArgs( + args map[string]any, +) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) if !ok || dev == "" { return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go index 9def73662..d03c4ef92 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/spi_linux.go @@ -38,25 +38,46 @@ type spiTransfer struct { func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { - return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) + return -1, ErrorResult( + fmt.Sprintf( + "failed to open %s: %v (check permissions and spidev module)", + devPath, + err, + ), + ) } // Set SPI mode - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode))) + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + spiIocWrMode, + uintptr(unsafe.Pointer(&mode)), + ) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno)) } // Set bits per word - _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits))) + _, _, errno = syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + spiIocWrBitsPerWord, + uintptr(unsafe.Pointer(&bits)), + ) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno)) } // Set max speed - _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed))) + _, _, errno = syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + spiIocWrMaxSpeedHz, + uintptr(unsafe.Pointer(&speed)), + ) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno)) @@ -117,7 +138,12 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult { bitsPerWord: bits, } - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + spiIocMessage1, + uintptr(unsafe.Pointer(&xfer)), + ) runtime.KeepAlive(txBuf) runtime.KeepAlive(rxBuf) if errno != 0 { @@ -174,7 +200,12 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult { bitsPerWord: bits, } - _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + uintptr(fd), + spiIocMessage1, + uintptr(unsafe.Pointer(&xfer)), + ) runtime.KeepAlive(txBuf) runtime.KeepAlive(rxBuf) if errno != 0 { diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 89ac7d4b5..601d3f937 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -316,7 +316,11 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { // ForUser should be truncated to 500 chars + "..." maxUserLen := 500 if len(result.ForUser) > maxUserLen+3 { // +3 for "..." - t.Errorf("ForUser should be truncated to ~%d chars, got: %d", maxUserLen, len(result.ForUser)) + t.Errorf( + "ForUser should be truncated to ~%d chars, got: %d", + maxUserLen, + len(result.ForUser), + ) } // ForLLM should have full content diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 387813e94..df72301a2 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -64,7 +64,13 @@ func RunToolLoop( llmOpts = map[string]any{} } // 3. Call LLM - response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) + response, err := config.Provider.Chat( + ctx, + messages, + providerToolDefs, + config.Model, + llmOpts, + ) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", map[string]any{ @@ -148,7 +154,14 @@ func RunToolLoop( var toolResult *ToolResult if config.Tools != nil { - toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) + toolResult = config.Tools.ExecuteWithContext( + ctx, + tc.Name, + tc.Arguments, + channel, + chatID, + nil, + ) } else { toolResult = ErrorResult("No tools available") } diff --git a/pkg/tools/validate_test.go b/pkg/tools/validate_test.go index e7f4f619a..accff9e3c 100644 --- a/pkg/tools/validate_test.go +++ b/pkg/tools/validate_test.go @@ -151,7 +151,10 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}}, + "color": map[string]any{ + "type": "string", + "enum": []any{"red", "green", "blue"}, + }, }, }, args: map[string]any{"color": "red"}, @@ -161,7 +164,10 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}}, + "color": map[string]any{ + "type": "string", + "enum": []any{"red", "green", "blue"}, + }, }, }, args: map[string]any{"color": "yellow"}, @@ -172,7 +178,10 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}}, + "color": map[string]any{ + "type": "string", + "enum": []string{"red", "green", "blue"}, + }, }, }, args: map[string]any{"color": "green"}, @@ -182,7 +191,10 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}}, + "color": map[string]any{ + "type": "string", + "enum": []string{"red", "green", "blue"}, + }, }, }, args: map[string]any{"color": "yellow"}, @@ -342,7 +354,11 @@ func TestValidateToolArgs_RegistryIntegration(t *testing.T) { } // Extra property — should fail with validation error - result = r.Execute(context.Background(), "read_file", map[string]any{"path": "/x", "__inject": true}) + result = r.Execute( + context.Background(), + "read_file", + map[string]any{"path": "/x", "__inject": true}, + ) if !result.IsError { t.Error("expected validation error for extra property") } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index de6187cfa..2c0de25f7 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -54,7 +54,8 @@ func TestWebTool_WebFetch_Success(t *testing.T) { } // ForUser should contain summary - if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { + if !strings.Contains(result.ForUser, "bytes") && + !strings.Contains(result.ForUser, "extractor") { t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) } } @@ -75,7 +76,11 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } ctx := context.Background() @@ -100,7 +105,11 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { func TestWebTool_WebFetch_InvalidURL(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } ctx := context.Background() @@ -125,7 +134,11 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) { func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } ctx := context.Background() @@ -141,7 +154,8 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { } // Should mention only http/https allowed - if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { + if !strings.Contains(result.ForLLM, "http/https") && + !strings.Contains(result.ForUser, "http/https") { t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) } } @@ -150,7 +164,11 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { func TestWebTool_WebFetch_MissingURL(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } ctx := context.Background() @@ -164,7 +182,8 @@ func TestWebTool_WebFetch_MissingURL(t *testing.T) { } // Should mention URL is required - if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { + if !strings.Contains(result.ForLLM, "url is required") && + !strings.Contains(result.ForUser, "url is required") { t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) } } @@ -184,7 +203,11 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { tool, err := NewWebFetchTool(1000, format, testFetchLimit) // Limit to 1000 chars if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } ctx := context.Background() @@ -216,7 +239,10 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { // Text should end with the truncation notice if text, ok := resultMap["text"].(string); ok { if !strings.HasSuffix(text, "[Content truncated due to size limit]") { - t.Errorf("Expected text to end with truncation notice, got: %q", text[max(0, len(text)-60):]) + t.Errorf( + "Expected text to end with truncation notice, got: %q", + text[max(0, len(text)-60):], + ) } } } @@ -263,11 +289,13 @@ func TestWebTool_WebFetch_TruncationNotice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", tt.contentType) - w.WriteHeader(http.StatusOK) - w.Write([]byte(tt.body)) - })) + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tt.contentType) + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.body)) + }), + ) defer server.Close() tool, err := NewWebFetchTool(maxChars, tt.format, testFetchLimit) @@ -291,7 +319,11 @@ func TestWebTool_WebFetch_TruncationNotice(t *testing.T) { } if !strings.HasSuffix(text, truncationNotice) { - t.Errorf("expected text to end with %q, got suffix: %q", truncationNotice, text[max(0, len(text)-60):]) + t.Errorf( + "expected text to end with %q, got suffix: %q", + truncationNotice, + text[max(0, len(text)-60):], + ) } if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { @@ -360,7 +392,11 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { // Initialize the tool tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } // Prepare the arguments pointing to the URL of our local mock server @@ -380,7 +416,8 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { // Search for the exact error string we set earlier in the Execute method expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) - if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { + if !strings.Contains(result.ForLLM, expectedErrorMsg) && + !strings.Contains(result.ForUser, expectedErrorMsg) { t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) } } @@ -533,7 +570,11 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } ctx := context.Background() @@ -718,7 +759,13 @@ func TestWebTool_WebFetch_PrivateHostAllowedByCIDRWhitelist(t *testing.T) { defer server.Close() host, _ := serverHostAndPort(t, server.URL) - tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{singleHostCIDR(t, host)}) + tool, err := NewWebFetchToolWithConfig( + 50000, + "", + format, + testFetchLimit, + []string{singleHostCIDR(t, host)}, + ) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } @@ -753,7 +800,10 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) { }) if result.IsError { - t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM) + t.Errorf( + "expected success when private host access is allowed in tests, got %q", + result.ForLLM, + ) } } @@ -973,7 +1023,11 @@ func TestIsPrivateOrRestrictedIP_Table(t *testing.T) { func TestWebTool_WebFetch_MissingDomain(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } ctx := context.Background() @@ -995,9 +1049,19 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) { } func TestNewWebFetchToolWithProxy(t *testing.T) { - tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", format, testFetchLimit, nil) + tool, err := NewWebFetchToolWithProxy( + 1024, + "http://127.0.0.1:7890", + format, + testFetchLimit, + nil, + ) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } else if tool.maxChars != 1024 { t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) } @@ -1008,7 +1072,11 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", format, testFetchLimit, nil) if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + logger.ErrorCF( + "agent", + "Failed to create web fetch tool", + map[string]any{"error": err.Error()}, + ) } if tool.maxChars != 50000 { @@ -1017,7 +1085,13 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { } func TestNewWebFetchToolWithConfig_InvalidPrivateHostWhitelist(t *testing.T) { - _, err := NewWebFetchToolWithConfig(1024, "", format, testFetchLimit, []string{"not-an-ip-or-cidr"}) + _, err := NewWebFetchToolWithConfig( + 1024, + "", + format, + testFetchLimit, + []string{"not-an-ip-or-cidr"}, + ) if err == nil { t.Fatal("expected invalid whitelist entry to fail") } @@ -1173,7 +1247,11 @@ func TestWebTool_TavilySearch_RangeMapping(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "results": []map[string]any{ - {"title": "Recent result", "url": "https://example.com/recent", "content": "snippet"}, + { + "title": "Recent result", + "url": "https://example.com/recent", + "content": "snippet", + }, }, }) })) @@ -1303,7 +1381,10 @@ func TestWebFetchTool_CloudflareChallenge_RetryFailsToo(t *testing.T) { // Should not be an error — the retry response is used as-is (403 is a valid HTTP response) if result.IsError { - t.Fatalf("expected non-error result even when retry is also blocked, got: %s", result.ForLLM) + t.Fatalf( + "expected non-error result even when retry is also blocked, got: %s", + result.ForLLM, + ) } // Status in the JSON result should reflect the 403 if !strings.Contains(result.ForLLM, "403") { @@ -1468,7 +1549,10 @@ func TestWebTool_GLMSearch_Success(t *testing.T) { t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } if r.Header.Get("Authorization") != "Bearer test-glm-key" { - t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization")) + t.Errorf( + "Expected Authorization Bearer test-glm-key, got %s", + r.Header.Get("Authorization"), + ) } var payload map[string]any @@ -1534,14 +1618,21 @@ func TestWebTool_GLMSearch_RangeMapping(t *testing.T) { t.Fatalf("failed to decode payload: %v", err) } if payload["search_recency_filter"] != "oneMonth" { - t.Fatalf("expected search_recency_filter=oneMonth, got %v", payload["search_recency_filter"]) + t.Fatalf( + "expected search_recency_filter=oneMonth, got %v", + payload["search_recency_filter"], + ) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "search_result": []map[string]any{ - {"title": "Recent GLM Result", "content": "snippet", "link": "https://example.com/glm-range"}, + { + "title": "Recent GLM Result", + "content": "snippet", + "link": "https://example.com/glm-range", + }, }, }) })) @@ -1573,14 +1664,21 @@ func TestWebTool_BaiduSearch_RangeMapping(t *testing.T) { t.Fatalf("failed to decode payload: %v", err) } if payload["search_recency_filter"] != "week" { - t.Fatalf("expected search_recency_filter=week for day fallback, got %v", payload["search_recency_filter"]) + t.Fatalf( + "expected search_recency_filter=week for day fallback, got %v", + payload["search_recency_filter"], + ) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "references": []map[string]any{ - {"title": "Recent Baidu Result", "url": "https://example.com/baidu", "content": "snippet"}, + { + "title": "Recent Baidu Result", + "url": "https://example.com/baidu", + "content": "snippet", + }, }, }) })) From 07748bf076adc564c51a98e360be6303efba6ba4 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 14:06:19 +0200 Subject: [PATCH 02/71] chore: revert unrelated golines formatting --- pkg/agent/context_budget_test.go | 13 +- pkg/agent/context_cache_test.go | 27 +--- pkg/agent/context_test.go | 41 +----- pkg/agent/definition_test.go | 18 +-- pkg/agent/eventbus_test.go | 25 +--- pkg/agent/hook_mount_test.go | 30 +---- pkg/agent/hook_process.go | 17 +-- pkg/agent/hooks.go | 20 +-- pkg/agent/hooks_test.go | 5 +- pkg/agent/instance_test.go | 6 +- pkg/agent/loop.go | 135 ++++--------------- pkg/agent/loop_mcp.go | 20 +-- pkg/agent/loop_media.go | 6 +- pkg/agent/loop_test.go | 121 ++++------------- pkg/agent/model_resolution.go | 8 +- pkg/agent/steering.go | 5 +- pkg/agent/steering_test.go | 30 +---- pkg/agent/subturn.go | 25 +--- pkg/agent/subturn_test.go | 10 +- pkg/config/defaults.go | 6 +- pkg/config/migration.go | 3 +- pkg/config/migration_integration_test.go | 41 +----- pkg/config/migration_test.go | 57 ++------ pkg/config/model_config_test.go | 24 +--- pkg/config/multikey_test.go | 10 +- pkg/config/security.go | 6 +- pkg/config/security_integration_test.go | 29 +--- pkg/tools/cron.go | 22 +-- pkg/tools/cron_test.go | 42 ++---- pkg/tools/edit.go | 17 +-- pkg/tools/edit_test.go | 6 +- pkg/tools/filesystem.go | 16 +-- pkg/tools/filesystem_test.go | 53 ++------ pkg/tools/i2c.go | 8 +- pkg/tools/i2c_linux.go | 42 +----- pkg/tools/mcp_tool.go | 34 +---- pkg/tools/mcp_tool_test.go | 5 +- pkg/tools/message_test.go | 10 +- pkg/tools/normalization.go | 30 +---- pkg/tools/result.go | 5 +- pkg/tools/result_test.go | 6 +- pkg/tools/search_tool.go | 52 ++------ pkg/tools/search_tools_test.go | 5 +- pkg/tools/send_file.go | 5 +- pkg/tools/send_file_test.go | 12 +- pkg/tools/shell.go | 72 ++-------- pkg/tools/shell_test.go | 95 +++---------- pkg/tools/skills_install.go | 15 +-- pkg/tools/skills_search.go | 5 +- pkg/tools/spawn_status.go | 6 +- pkg/tools/spawn_status_test.go | 12 +- pkg/tools/spi.go | 8 +- pkg/tools/spi_linux.go | 43 +----- pkg/tools/subagent_tool_test.go | 6 +- pkg/tools/toolloop.go | 17 +-- pkg/tools/validate_test.go | 26 +--- pkg/tools/web_test.go | 162 +++++------------------ 57 files changed, 297 insertions(+), 1278 deletions(-) diff --git a/pkg/agent/context_budget_test.go b/pkg/agent/context_budget_test.go index c8993746f..870f0fbe6 100644 --- a/pkg/agent/context_budget_test.go +++ b/pkg/agent/context_budget_test.go @@ -500,11 +500,8 @@ func TestEstimateMessageTokens_ReasoningContent(t *testing.T) { reasoningTokens := estimateMessageTokens(withReasoning) if reasoningTokens <= plainTokens { - t.Errorf( - "message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)", - reasoningTokens, - plainTokens, - ) + t.Errorf("message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)", + reasoningTokens, plainTokens) } } @@ -767,11 +764,7 @@ func TestEstimateMessageTokens_WithReasoningAndMedia(t *testing.T) { tokensNoReasoning := estimateMessageTokens(msgNoReasoning) if tokens <= tokensNoReasoning { - t.Errorf( - "reasoning content should add tokens: with=%d, without=%d", - tokens, - tokensNoReasoning, - ) + t.Errorf("reasoning content should add tokens: with=%d, without=%d", tokens, tokensNoReasoning) } } diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index ae6ff18cc..81a1534b9 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -82,16 +82,7 @@ func TestSingleSystemMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages( - tt.history, - tt.summary, - tt.message, - nil, - "test", - "chat1", - "", - "", - ) + msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "") systemCount := 0 for _, m := range msgs { @@ -177,16 +168,7 @@ func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages( - nil, - "", - "hello", - nil, - "discord", - "chat1", - tt.senderID, - tt.senderDisplayName, - ) + msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName) sys := msgs[0].Content if tt.wantSection { @@ -400,10 +382,7 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) { // Cache should auto-invalidate because file went from absent -> present sp2 := cb.BuildSystemPromptWithCache() if !strings.Contains(sp2, tt.checkField) { - t.Errorf( - "cache not invalidated on new file creation: expected %q in prompt", - tt.checkField, - ) + t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField) } }) } diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index c3b9ed6a0..0d7948eef 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -151,19 +151,7 @@ func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) { if len(result) != 9 { t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) } - assertRoles( - t, - result, - "user", - "assistant", - "tool", - "tool", - "assistant", - "user", - "assistant", - "tool", - "assistant", - ) + assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant") } func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) { @@ -182,18 +170,7 @@ func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) { if len(result) != 8 { t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result)) } - assertRoles( - t, - result, - "user", - "assistant", - "tool", - "tool", - "assistant", - "tool", - "tool", - "assistant", - ) + assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant") } func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { @@ -327,17 +304,5 @@ func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) { if len(result) != 9 { t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) } - assertRoles( - t, - result, - "user", - "assistant", - "tool", - "assistant", - "user", - "user", - "assistant", - "tool", - "assistant", - ) + assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant") } diff --git a/pkg/agent/definition_test.go b/pkg/agent/definition_test.go index b3068d134..5ee996967 100644 --- a/pkg/agent/definition_test.go +++ b/pkg/agent/definition_test.go @@ -61,12 +61,8 @@ Act directly and use tools first. if len(definition.Agent.Frontmatter.Skills) != 2 { t.Fatalf("expected skills to be parsed, got %v", definition.Agent.Frontmatter.Skills) } - if len(definition.Agent.Frontmatter.MCPServers) != 1 || - definition.Agent.Frontmatter.MCPServers[0] != "github" { - t.Fatalf( - "expected mcpServers to be parsed, got %v", - definition.Agent.Frontmatter.MCPServers, - ) + if len(definition.Agent.Frontmatter.MCPServers) != 1 || definition.Agent.Frontmatter.MCPServers[0] != "github" { + t.Fatalf("expected mcpServers to be parsed, got %v", definition.Agent.Frontmatter.MCPServers) } if definition.Agent.Frontmatter.Fields["metadata"] == nil { t.Fatal("expected arbitrary frontmatter fields to remain available") @@ -100,10 +96,7 @@ func TestLoadAgentDefinitionFallsBackToLegacyAgentsMarkdown(t *testing.T) { t.Fatal("expected AGENTS.md to be loaded") } if definition.Agent.RawFrontmatter != "" { - t.Fatalf( - "legacy AGENTS.md should not have frontmatter, got %q", - definition.Agent.RawFrontmatter, - ) + t.Fatalf("legacy AGENTS.md should not have frontmatter, got %q", definition.Agent.RawFrontmatter) } if !strings.Contains(definition.Agent.Body, "Keep compatibility") { t.Fatalf("expected legacy body to be preserved, got %q", definition.Agent.Body) @@ -166,10 +159,7 @@ Keep going. len(definition.Agent.Frontmatter.Skills) != 0 || len(definition.Agent.Frontmatter.MCPServers) != 0 || len(definition.Agent.Frontmatter.Fields) != 0 { - t.Fatalf( - "expected invalid frontmatter to decode as empty struct, got %+v", - definition.Agent.Frontmatter, - ) + t.Fatalf("expected invalid frontmatter to decode as empty struct, got %+v", definition.Agent.Frontmatter) } } diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 0b0e351dd..19a1ea9eb 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -275,13 +275,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { resultCh := make(chan string, 1) go func() { - resp, _ := al.ProcessDirectWithChannel( - context.Background(), - "do something", - "test-session", - "test", - "chat1", - ) + resp, _ := al.ProcessDirectWithChannel(context.Background(), "do something", "test-session", "test", "chat1") resultCh <- resp }() @@ -344,11 +338,7 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) { t.Fatalf("expected steering interrupt kind, got %q", interruptPayload.Kind) } if interruptPayload.ContentLen != len("change course") { - t.Fatalf( - "expected interrupt content len %d, got %d", - len("change course"), - interruptPayload.ContentLen, - ) + t.Fatalf("expected interrupt content len %d, got %d", len("change course"), interruptPayload.ContentLen) } } @@ -370,9 +360,7 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { }, } - contextErr := stringError( - "InvalidParameter: Total tokens of image and text exceed max message tokens", - ) + contextErr := stringError("InvalidParameter: Total tokens of image and text exceed max message tokens") provider := &failFirstMockProvider{ failures: 1, failError: contextErr, @@ -615,12 +603,7 @@ func collectEventStream(ch <-chan Event) []Event { } } -func waitForEvent( - t *testing.T, - ch <-chan Event, - timeout time.Duration, - match func(Event) bool, -) Event { +func waitForEvent(t *testing.T, ch <-chan Event, timeout time.Duration, match func(Event) bool) Event { t.Helper() timer := time.NewTimer(timeout) diff --git a/pkg/agent/hook_mount_test.go b/pkg/agent/hook_mount_test.go index 068f8da10..85d8f5c11 100644 --- a/pkg/agent/hook_mount_test.go +++ b/pkg/agent/hook_mount_test.go @@ -40,11 +40,7 @@ func (h *builtinAutoHook) AfterLLM( return next, HookDecision{Action: HookActionModify}, nil } -func newConfiguredHookLoop( - t *testing.T, - provider *llmHookTestProvider, - hooks config.HooksConfig, -) *AgentLoop { +func newConfiguredHookLoop(t *testing.T, provider *llmHookTestProvider, hooks config.HooksConfig) *AgentLoop { t.Helper() cfg := &config.Config{ @@ -106,13 +102,7 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T) }) defer al.Close() - resp, err := al.ProcessDirectWithChannel( - context.Background(), - "hello", - "session-1", - "cli", - "direct", - ) + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -150,13 +140,7 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T) }) defer al.Close() - resp, err := al.ProcessDirectWithChannel( - context.Background(), - "hello", - "session-1", - "cli", - "direct", - ) + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -188,13 +172,7 @@ func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testin }) defer al.Close() - _, err := al.ProcessDirectWithChannel( - context.Background(), - "hello", - "session-1", - "cli", - "direct", - ) + _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-1", "cli", "direct") if err == nil { t.Fatal("expected invalid configured hook error") } diff --git a/pkg/agent/hook_process.go b/pkg/agent/hook_process.go index 9b623ce42..e5632913d 100644 --- a/pkg/agent/hook_process.go +++ b/pkg/agent/hook_process.go @@ -98,11 +98,7 @@ type processHookAfterToolResponse struct { Result *ToolResultHookResponse `json:"result,omitempty"` } -func NewProcessHook( - ctx context.Context, - name string, - opts ProcessHookOptions, -) (*ProcessHook, error) { +func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (*ProcessHook, error) { if len(opts.Command) == 0 { return nil, fmt.Errorf("process hook command is required") } @@ -266,10 +262,7 @@ func (ph *ProcessHook) AfterTool( return resp.Result, HookDecision{Action: resp.Action, Reason: resp.Reason}, nil } -func (ph *ProcessHook) ApproveTool( - ctx context.Context, - req *ToolApprovalRequest, -) (ApprovalDecision, error) { +func (ph *ProcessHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { if ph == nil || !ph.opts.ApproveTool { return ApprovalDecision{Approved: true}, nil } @@ -480,11 +473,7 @@ func (ph *ProcessHook) removePending(id uint64) { } } -func (al *AgentLoop) MountProcessHook( - ctx context.Context, - name string, - opts ProcessHookOptions, -) error { +func (al *AgentLoop) MountProcessHook(ctx context.Context, name string, opts ProcessHookOptions) error { if al == nil { return fmt.Errorf("agent loop is nil") } diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index 4f63d0652..c1ef58ffd 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -79,14 +79,8 @@ type LLMInterceptor interface { } type ToolInterceptor interface { - BeforeTool( - ctx context.Context, - call *ToolCallHookRequest, - ) (*ToolCallHookRequest, HookDecision, error) - AfterTool( - ctx context.Context, - result *ToolResultHookResponse, - ) (*ToolResultHookResponse, HookDecision, error) + BeforeTool(ctx context.Context, call *ToolCallHookRequest) (*ToolCallHookRequest, HookDecision, error) + AfterTool(ctx context.Context, result *ToolResultHookResponse) (*ToolResultHookResponse, HookDecision, error) } type ToolApprover interface { @@ -301,10 +295,7 @@ func (hm *HookManager) dispatchEvents() { } } -func (hm *HookManager) BeforeLLM( - ctx context.Context, - req *LLMHookRequest, -) (*LLMHookRequest, HookDecision) { +func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision) { if hm == nil || req == nil { return req, HookDecision{Action: HookActionContinue} } @@ -335,10 +326,7 @@ func (hm *HookManager) BeforeLLM( return current, HookDecision{Action: HookActionContinue} } -func (hm *HookManager) AfterLLM( - ctx context.Context, - resp *LLMHookResponse, -) (*LLMHookResponse, HookDecision) { +func (hm *HookManager) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision) { if hm == nil || resp == nil { return resp, HookDecision{Action: HookActionContinue} } diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index d112d4c07..49e1b1784 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -293,10 +293,7 @@ func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { type denyApprovalHook struct{} -func (h *denyApprovalHook) ApproveTool( - ctx context.Context, - req *ToolApprovalRequest, -) (ApprovalDecision, error) { +func (h *denyApprovalHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { return ApprovalDecision{ Approved: false, Reason: "blocked", diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index a933a6493..e296a18cb 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -156,11 +156,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) } if agent.Candidates[0].Provider != tt.wantProvider { - t.Fatalf( - "candidate provider = %q, want %q", - agent.Candidates[0].Provider, - tt.wantProvider, - ) + t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider) } if agent.Candidates[0].Model != tt.wantModel { t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 2193bbad3..ef2951365 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -192,11 +192,7 @@ func registerSharedTools( Proxy: cfg.Tools.Web.Proxy, }) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web search tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) } else if searchTool != nil { agent.Tools.Register(searchTool) } @@ -209,11 +205,7 @@ func registerSharedTools( cfg.Tools.Web.FetchLimitBytes, cfg.Tools.Web.PrivateHostWhitelist) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } else { agent.Tools.Register(fetchTool) } @@ -483,12 +475,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue( - ctx, - target.SessionKey, - target.Channel, - target.ChatID, - ) + continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { logger.WarnCF("agent", "Failed to continue queued steering", map[string]any{ @@ -516,22 +503,14 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue( - ctx, - target.SessionKey, - target.Channel, - target.ChatID, - ) + continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { - logger.WarnCF( - "agent", - "Failed to continue queued steering after shutdown drain", + logger.WarnCF("agent", "Failed to continue queued steering after shutdown drain", map[string]any{ "channel": target.Channel, "chat_id": target.ChatID, "error": continueErr.Error(), - }, - ) + }) return } if continued == "" { @@ -586,15 +565,11 @@ func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, active msgScope, _, scopeOK := al.resolveSteeringTarget(msg) if !scopeOK || msgScope != activeScope { if err := al.requeueInboundMessage(msg); err != nil { - logger.WarnCF( - "agent", - "Failed to requeue non-steering inbound message", - map[string]any{ - "error": err.Error(), - "channel": msg.Channel, - "sender_id": msg.SenderID, - }, - ) + logger.WarnCF("agent", "Failed to requeue non-steering inbound message", map[string]any{ + "error": err.Error(), + "channel": msg.Channel, + "sender_id": msg.SenderID, + }) } continue } @@ -628,10 +603,7 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } -func (al *AgentLoop) PublishResponseIfNeeded( - ctx context.Context, - channel, chatID, response string, -) { +func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { if response == "" { return } @@ -1081,10 +1053,7 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and // replaces audio annotations in msg.Content with the transcribed text. // Returns the (possibly modified) message and true if audio was transcribed. -func (al *AgentLoop) transcribeAudioInMessage( - ctx context.Context, - msg bus.InboundMessage, -) (bus.InboundMessage, bool) { +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { return msg, false } @@ -1094,11 +1063,7 @@ func (al *AgentLoop) transcribeAudioInMessage( for _, ref := range msg.Media { path, meta, err := al.mediaStore.ResolveWithMeta(ref) if err != nil { - logger.WarnCF( - "voice", - "Failed to resolve media ref", - map[string]any{"ref": ref, "error": err}, - ) + logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) continue } if !utils.IsAudioFile(meta.Filename, meta.ContentType) { @@ -1176,11 +1141,7 @@ func (al *AgentLoop) sendTranscriptionFeedback( ReplyToMessageID: messageID, }) if err != nil { - logger.WarnCF( - "voice", - "Failed to send transcription feedback", - map[string]any{"error": err.Error()}, - ) + logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) } } @@ -1381,9 +1342,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.runAgentLoop(ctx, agent, opts) } -func (al *AgentLoop) resolveMessageRoute( - msg bus.InboundMessage, -) (routing.ResolvedRoute, *AgentInstance, error) { +func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { registry := al.GetRegistry() route := registry.ResolveRoute(routing.RouteInput{ Channel: msg.Channel, @@ -1399,10 +1358,7 @@ func (al *AgentLoop) resolveMessageRoute( agent = registry.GetDefaultAgent() } if agent == nil { - return routing.ResolvedRoute{}, nil, fmt.Errorf( - "no agent available for route (agent_id=%s)", - route.AgentID, - ) + return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) } return route, agent, nil @@ -1727,11 +1683,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er ts.recordPersistedMessage(rootMsg) } - activeCandidates, activeModel, usedLight := al.selectCandidates( - ts.agent, - ts.userMessage, - messages, - ) + activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) activeProvider := ts.agent.Provider if usedLight && ts.agent.LightProvider != nil { activeProvider = ts.agent.LightProvider @@ -2704,15 +2656,12 @@ turnLoop: } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF( - "agent", - "Steering arrived after turn completion; continuing turn before finalizing", + logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", map[string]any{ "agent_id": ts.agent.ID, "steering_count": len(steerMsgs), "session_key": ts.sessionKey, - }, - ) + }) pendingMessages = append(pendingMessages, steerMsgs...) finalContent = "" goto turnLoop @@ -2828,18 +2777,11 @@ func (al *AgentLoop) selectCandidates( "score": score, "threshold": agent.Router.Threshold(), }) - return agent.LightCandidates, resolvedCandidateModel( - agent.LightCandidates, - agent.Router.LightModel(), - ), true + return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true } // maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize( - agent *AgentInstance, - sessionKey string, - turnScope turnEventScope, -) { +func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { newHistory := agent.Sessions.GetHistory(sessionKey) tokenEstimate := al.estimateTokens(newHistory) threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 @@ -2873,10 +2815,7 @@ type compressionResult struct { // prompt is built dynamically by BuildMessages and is NOT stored here. // The compression note is recorded in the session summary so that // BuildMessages can include it in the next system prompt. -func (al *AgentLoop) forceCompression( - agent *AgentInstance, - sessionKey string, -) (compressionResult, bool) { +func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) { history := agent.Sessions.GetHistory(sessionKey) if len(history) <= 2 { return compressionResult{}, false @@ -3029,11 +2968,7 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { } // summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession( - agent *AgentInstance, - sessionKey string, - turnScope turnEventScope, -) { +func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -3385,10 +3320,7 @@ func (al *AgentLoop) applyExplicitSkillCommand( skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) if !ok { - return true, true, fmt.Sprintf( - "Unknown skill: %s\nUse /list skills to see installed skills.", - arg, - ) + return true, true, fmt.Sprintf("Unknown skill: %s\nUse /list skills to see installed skills.", arg) } if len(parts) < 3 { @@ -3415,10 +3347,7 @@ func (al *AgentLoop) applyExplicitSkillCommand( return true, false, "" } -func (al *AgentLoop) buildCommandsRuntime( - agent *AgentInstance, - opts *processOptions, -) *commands.Runtime { +func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime { registry := al.GetRegistry() cfg := al.GetConfig() rt := &commands.Runtime{ @@ -3462,10 +3391,7 @@ func (al *AgentLoop) buildCommandsRuntime( rt.ListSkillNames = agent.ContextBuilder.ListSkillNames } rt.GetModelInfo = func() (string, string) { - return agent.Model, resolvedCandidateProvider( - agent.Candidates, - cfg.Agents.Defaults.Provider, - ) + return agent.Model, resolvedCandidateProvider(agent.Candidates, cfg.Agents.Defaults.Provider) } rt.SwitchModel = func(value string) (string, error) { value = strings.TrimSpace(value) @@ -3479,12 +3405,7 @@ func (al *AgentLoop) buildCommandsRuntime( return "", fmt.Errorf("failed to initialize model %q: %w", value, err) } - nextCandidates := resolveModelCandidates( - cfg, - cfg.Agents.Defaults.Provider, - modelCfg.Model, - agent.Fallbacks, - ) + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks) if len(nextCandidates) == 0 { return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) } diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index 644f7168e..97debbc33 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -65,11 +65,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 { - logger.WarnCF( - "agent", - "MCP is enabled but no servers are configured, skipping MCP initialization", - nil, - ) + logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil) return nil } @@ -80,11 +76,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } } if !findValidServer { - logger.WarnCF( - "agent", - "MCP is enabled but no valid servers are configured, skipping MCP initialization", - nil, - ) + logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil) return nil } @@ -201,14 +193,10 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } if useRegex { - agent.Tools.Register( - tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults), - ) + agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) } if useBM25 { - agent.Tools.Register( - tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults), - ) + agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) } } } diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go index 6958f51cb..e8314c10d 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/loop_media.go @@ -25,11 +25,7 @@ import ( // 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. // Returns a new slice; original messages are not mutated. -func resolveMediaRefs( - messages []providers.Message, - store media.MediaStore, - maxSize int, -) []providers.Message { +func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { if store == nil { return messages } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 9911c5cb7..25d20c689 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -591,9 +591,7 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. store := media.NewFileMediaStore() al.SetMediaStore(store) telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} - al.SetChannelManager( - newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel), - ) + al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) imagePath := filepath.Join(tmpDir, "screen.png") if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { @@ -615,10 +613,7 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. t.Fatalf("processMessage() error = %v", err) } if response != "" { - t.Fatalf( - "expected no final response when media tool already handled delivery, got %q", - response, - ) + t.Fatalf("expected no final response when media tool already handled delivery, got %q", response) } if provider.calls != 1 { t.Fatalf("expected exactly 1 LLM call, got %d", provider.calls) @@ -631,20 +626,13 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. } if len(telegramChannel.sentMedia) != 1 { - t.Fatalf( - "expected exactly 1 synchronously sent media message, got %d", - len(telegramChannel.sentMedia), - ) + t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) } - if telegramChannel.sentMedia[0].Channel != "telegram" || - telegramChannel.sentMedia[0].ChatID != "chat1" { + if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" { t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0]) } if len(telegramChannel.sentMedia[0].Parts) != 1 { - t.Fatalf( - "expected exactly 1 sent media part, got %d", - len(telegramChannel.sentMedia[0].Parts), - ) + t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts)) } select { @@ -672,8 +660,7 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. t.Fatal("expected session history to be saved") } last := history[len(history)-1] - if last.Role != "assistant" || - last.Content != "Requested output delivered via tool attachment." { + if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." { t.Fatalf("expected handled assistant summary in history, got %+v", last) } } @@ -698,9 +685,7 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes store := media.NewFileMediaStore() al.SetMediaStore(store) telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} - al.SetChannelManager( - newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel), - ) + al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) imagePath := filepath.Join(tmpDir, "screen-steering.png") if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { @@ -729,10 +714,7 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes t.Fatalf("expected 2 LLM calls after queued steering, got %d", provider.calls) } if len(telegramChannel.sentMedia) != 1 { - t.Fatalf( - "expected exactly 1 synchronously sent media message, got %d", - len(telegramChannel.sentMedia), - ) + t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) } } @@ -751,9 +733,7 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { store := media.NewFileMediaStore() al.SetMediaStore(store) telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} - al.SetChannelManager( - newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel), - ) + al.SetChannelManager(newStartedTestChannelManager(t, msgBus, store, "telegram", telegramChannel)) mediaDir := media.TempDir() if err := os.MkdirAll(mediaDir, 0o700); err != nil { @@ -786,20 +766,13 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) { } if len(telegramChannel.sentMedia) != 1 { - t.Fatalf( - "expected exactly 1 synchronously sent media message, got %d", - len(telegramChannel.sentMedia), - ) + t.Fatalf("expected exactly 1 synchronously sent media message, got %d", len(telegramChannel.sentMedia)) } - if telegramChannel.sentMedia[0].Channel != "telegram" || - telegramChannel.sentMedia[0].ChatID != "chat1" { + if telegramChannel.sentMedia[0].Channel != "telegram" || telegramChannel.sentMedia[0].ChatID != "chat1" { t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0]) } if len(telegramChannel.sentMedia[0].Parts) != 1 { - t.Fatalf( - "expected exactly 1 sent media part, got %d", - len(telegramChannel.sentMedia[0].Parts), - ) + t.Fatalf("expected exactly 1 sent media part, got %d", len(telegramChannel.sentMedia[0].Parts)) } select { @@ -1210,10 +1183,7 @@ func (m *handledMediaWithSteeringTool) Parameters() map[string]any { } } -func (m *handledMediaWithSteeringTool) Execute( - ctx context.Context, - args map[string]any, -) *tools.ToolResult { +func (m *handledMediaWithSteeringTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { if err := m.loop.Steer(providers.Message{Role: "user", Content: "what about this instead?"}); err != nil { return tools.ErrorResult(err.Error()).WithError(err) } @@ -1366,11 +1336,7 @@ func newStrictChatCompletionTestServer( })) } -func (h testHelper) executeAndGetResponse( - tb testing.TB, - ctx context.Context, - msg bus.InboundMessage, -) string { +func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { // Use a short timeout to avoid hanging timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) defer cancel() @@ -1501,10 +1467,7 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { t.Fatalf("unexpected /foo reply: %q", fooResp) } if provider.calls != 1 { - t.Fatalf( - "LLM should be called exactly once after /foo passthrough, calls=%d", - provider.calls, - ) + t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls) } newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ @@ -1654,10 +1617,7 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { } if provider.calls != 0 { - t.Fatalf( - "LLM should not be called for rejected /switch and /show, calls=%d", - provider.calls, - ) + t.Fatalf("LLM should not be called for rejected /switch and /show, calls=%d", provider.calls) } } @@ -1675,13 +1635,7 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t remoteCalls := 0 remoteModel := "" - remoteServer := newChatCompletionTestServer( - t, - "remote", - "remote reply", - &remoteCalls, - &remoteModel, - ) + remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel) defer remoteServer.Close() cfg := &config.Config{ @@ -2004,9 +1958,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { msgBus := bus.NewMessageBus() // Create a provider that fails once with a context error - contextErr := fmt.Errorf( - "InvalidParameter: Total tokens of image and text exceed max message tokens", - ) + contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens") provider := &failFirstMockProvider{ failures: 1, failError: contextErr, @@ -2087,13 +2039,7 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) { provider := &simpleMockProvider{response: ""} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.ProcessDirectWithChannel( - context.Background(), - "hello", - "empty-response", - "test", - "chat1", - ) + response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "empty-response", "test", "chat1") if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -2125,13 +2071,7 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) { al := NewAgentLoop(cfg, msgBus, provider) al.RegisterTool(&toolLimitTestTool{}) - response, err := al.ProcessDirectWithChannel( - context.Background(), - "hello", - "tool-limit", - "test", - "chat1", - ) + response, err := al.ProcessDirectWithChannel(context.Background(), "hello", "tool-limit", "test", "chat1") if err != nil { t.Fatalf("ProcessDirectWithChannel failed: %v", err) } @@ -2449,9 +2389,7 @@ func TestHandleReasoning(t *testing.T) { break } if msg.Content == "should timeout" { - t.Fatal( - "expected reasoning message to be dropped when bus is full, but it was published", - ) + t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") } } } @@ -2545,12 +2483,7 @@ func TestProcessHeartbeat_DoesNotPublishToolFeedback(t *testing.T) { provider := &toolFeedbackProvider{filePath: heartbeatFile} al := NewAgentLoop(cfg, msgBus, provider) - response, err := al.ProcessHeartbeat( - context.Background(), - "check heartbeat tasks", - "telegram", - "chat-1", - ) + response, err := al.ProcessHeartbeat(context.Background(), "check heartbeat tasks", "telegram", "chat-1") if err != nil { t.Fatalf("ProcessHeartbeat() error = %v", err) } @@ -3035,14 +2968,8 @@ func TestProcessMessage_ContextOverflowRecovery(t *testing.T) { agent := al.GetRegistry().GetDefaultAgent() for i := 0; i < 5; i++ { - agent.Sessions.AddFullMessage( - sessionKey, - providers.Message{Role: "user", Content: "heavy message"}, - ) - agent.Sessions.AddFullMessage( - sessionKey, - providers.Message{Role: "assistant", Content: "response"}, - ) + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "heavy message"}) + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"}) } response, err := al.processMessage(context.Background(), bus.InboundMessage{ diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index d5c2f74ea..140cff718 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -26,8 +26,7 @@ func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) return "", false } - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && - strings.TrimSpace(mc.Model) != "" { + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { return ensureProtocol(mc.Model), true } @@ -79,10 +78,7 @@ func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallbac return fallback } -func resolvedModelConfig( - cfg *config.Config, - modelName, workspace string, -) (*config.ModelConfig, error) { +func resolvedModelConfig(cfg *config.Config, modelName, workspace string) (*config.ModelConfig, error) { if cfg == nil { return nil, fmt.Errorf("config is nil") } diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index 7ce918dd8..ad6613e8c 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -325,10 +325,7 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { // user has since enqueued steering messages. // // If no steering messages are pending, it returns an empty string. -func (al *AgentLoop) Continue( - ctx context.Context, - sessionKey, channel, chatID string, -) (string, error) { +func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) { if active := al.GetActiveTurn(); active != nil { return "", fmt.Errorf("turn %s is still active", active.TurnID) } diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index deb4f07c5..75ba9861d 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -896,10 +896,7 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { defer cancelNoExtra() select { case out2 := <-msgBus.OutboundChan(): - t.Fatalf( - "expected stale direct response to be suppressed, got extra outbound %q", - out2.Content, - ) + t.Fatalf("expected stale direct response to be suppressed, got extra outbound %q", out2.Content) case <-noExtraCtx.Done(): } @@ -1047,11 +1044,7 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) { if err = os.WriteFile(pngPath, pngHeader, 0o644); err != nil { t.Fatalf("WriteFile failed: %v", err) } - ref, err := store.Store( - pngPath, - media.MediaMeta{Filename: "steer.png", ContentType: "image/png"}, - "test", - ) + ref, err := store.Store(pngPath, media.MediaMeta{Filename: "steer.png", ContentType: "image/png"}, "test") if err != nil { t.Fatalf("Store failed: %v", err) } @@ -1243,10 +1236,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { t.Fatalf("expected 2 provider calls, got %d", calls) } if terminalToolsCount != 0 { - t.Fatalf( - "expected graceful terminal call to disable tools, got %d tool defs", - terminalToolsCount, - ) + t.Fatalf("expected graceful terminal call to disable tools, got %d tool defs", terminalToolsCount) } foundHint := false @@ -1257,8 +1247,7 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) { if msg.Role == "user" && msg.Content == expectedHint { foundHint = true } - if msg.Role == "tool" && msg.ToolCallID == "call_2" && - msg.Content == "Skipped due to graceful interrupt." { + if msg.Role == "tool" && msg.ToolCallID == "call_2" && msg.Content == "Skipped due to graceful interrupt." { foundSkipped = true } } @@ -1550,8 +1539,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { foundSkipped := false for _, m := range msgs { - if m.Role == "tool" && m.ToolCallID == "call_2" && - m.Content == "Skipped due to queued user message." { + if m.Role == "tool" && m.ToolCallID == "call_2" && m.Content == "Skipped due to queued user message." { foundSkipped = true break } @@ -1559,13 +1547,7 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) { if !foundSkipped { // Log what we actually got for i, m := range msgs { - t.Logf( - "msg[%d]: role=%s toolCallID=%s content=%s", - i, - m.Role, - m.ToolCallID, - truncate(m.Content, 80), - ) + t.Logf("msg[%d]: role=%s toolCallID=%s content=%s", i, m.Role, m.ToolCallID, truncate(m.Content, 80)) } t.Fatal("expected skipped tool result for call_2") } diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 4fcbb089c..f5ba412ab 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -505,12 +505,7 @@ func spawnSubTurn( // Event emissions: // - SubTurnResultDeliveredEvent: successful delivery to channel // - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full) -func deliverSubTurnResult( - al *AgentLoop, - parentTS *turnState, - childID string, - result *tools.ToolResult, -) { +func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, result *tools.ToolResult) { // Let GC clean up the pendingResults channel; parent Finish will no longer close it. // We use defer/recover to catch any unlikely channel panics if it were ever closed. defer func() { @@ -521,14 +516,9 @@ func deliverSubTurnResult( "recover": r, }) if result != nil && al != nil { - al.emitEvent( - EventKindSubTurnOrphan, + al.emitEvent(EventKindSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), - SubTurnOrphanPayload{ - ParentTurnID: parentTS.turnID, - ChildTurnID: childID, - Reason: "panic", - }, + SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "panic"}, ) } } @@ -541,14 +531,9 @@ func deliverSubTurnResult( // If parent turn has already finished, treat this as an orphan result if isFinished || resultChan == nil { if result != nil && al != nil { - al.emitEvent( - EventKindSubTurnOrphan, + al.emitEvent(EventKindSubTurnOrphan, parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), - SubTurnOrphanPayload{ - ParentTurnID: parentTS.turnID, - ChildTurnID: childID, - Reason: "parent_finished", - }, + SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"}, ) } return diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index ef5a03b20..6a2ba835d 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -571,8 +571,7 @@ func TestHardAbortSessionRollback(t *testing.T) { } // Verify the content matches the initial state - if finalHistory[0].Content != "initial message 1" || - finalHistory[1].Content != "initial response 1" { + if finalHistory[0].Content != "initial message 1" || finalHistory[1].Content != "initial response 1" { t.Error("history content does not match initial state after rollback") } } @@ -1291,12 +1290,7 @@ func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) { finalOrphan := orphanCount mu.Unlock() - t.Logf( - "Delivered: %d, Orphan: %d, Total: %d", - finalDelivered, - finalOrphan, - finalDelivered+finalOrphan, - ) + t.Logf("Delivered: %d, Orphan: %d, Total: %d", finalDelivered, finalOrphan, finalDelivered+finalOrphan) // With the new drainPendingResults behavior, the total events may be >= numResults // because Finish() drains remaining results from the channel and emits them as orphans. diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 20e2e531d..bc4ab0649 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -65,11 +65,7 @@ func DefaultConfig() *Config { Enabled: true, Text: FlexibleStringSlice{"Thinking... 💭"}, }, - Streaming: StreamingConfig{ - Enabled: true, - ThrottleSeconds: 3, - MinGrowthChars: 200, - }, + Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200}, UseMarkdownV2: false, }, Feishu: FeishuConfig{ diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 43f9645a2..fee800a76 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -335,8 +335,7 @@ func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 { providerNames: []string{"github_copilot", "copilot"}, protocol: "github-copilot", buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { - if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && - p.GitHubCopilot.ConnectMode == "" { + if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" { return modelConfigV0{}, false } return modelConfigV0{ diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go index b6a70c2ef..bc8160967 100644 --- a/pkg/config/migration_integration_test.go +++ b/pkg/config/migration_integration_test.go @@ -72,11 +72,7 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) { // CRITICAL: Verify that user's settings are preserved // This was the bug - these settings were lost when Workspace was empty if cfg.Agents.Defaults.Provider != "openai" { - t.Errorf( - "Provider = %q, want %q (user's setting should be preserved)", - cfg.Agents.Defaults.Provider, - "openai", - ) + t.Errorf("Provider = %q, want %q (user's setting should be preserved)", cfg.Agents.Defaults.Provider, "openai") } // Old "model" field is migrated to "model_name" field if cfg.Agents.Defaults.ModelName != "gpt-4o" { @@ -303,11 +299,7 @@ func TestMigration_Integration_PreservesAllAgentsFields(t *testing.T) { t.Errorf("Agent.ID = %q, want %q", cfg.Agents.List[0].ID, "special-agent") } if cfg.Agents.List[0].Workspace != "/special/workspace" { - t.Errorf( - "Agent.Workspace = %q, want %q", - cfg.Agents.List[0].Workspace, - "/special/workspace", - ) + t.Errorf("Agent.Workspace = %q, want %q", cfg.Agents.List[0].Workspace, "/special/workspace") } // Workspace should have default since it was empty in legacy config @@ -370,10 +362,7 @@ func TestMigration_Integration_ChannelsConfigMigrated(t *testing.T) { // OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 { - t.Errorf( - "len(OneBot.GroupTrigger.Prefixes) = %d, want 2", - len(cfg.Channels.OneBot.GroupTrigger.Prefixes), - ) + t.Errorf("len(OneBot.GroupTrigger.Prefixes) = %d, want 2", len(cfg.Channels.OneBot.GroupTrigger.Prefixes)) } else { if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" { t.Errorf("Prefixes[0] = %q, want %q", cfg.Channels.OneBot.GroupTrigger.Prefixes[0], "/") @@ -454,25 +443,13 @@ func TestMigration_Integration_RoundTrip_SerializeAndLoad(t *testing.T) { // Verify configs are identical if cfg2.Agents.Defaults.Provider != cfg1.Agents.Defaults.Provider { - t.Errorf( - "Provider changed from %q to %q", - cfg1.Agents.Defaults.Provider, - cfg2.Agents.Defaults.Provider, - ) + t.Errorf("Provider changed from %q to %q", cfg1.Agents.Defaults.Provider, cfg2.Agents.Defaults.Provider) } if cfg2.Agents.Defaults.ModelName != cfg1.Agents.Defaults.ModelName { - t.Errorf( - "ModelName changed from %q to %q", - cfg1.Agents.Defaults.ModelName, - cfg2.Agents.Defaults.ModelName, - ) + t.Errorf("ModelName changed from %q to %q", cfg1.Agents.Defaults.ModelName, cfg2.Agents.Defaults.ModelName) } if cfg2.Agents.Defaults.MaxTokens != cfg1.Agents.Defaults.MaxTokens { - t.Errorf( - "MaxTokens changed from %d to %d", - cfg1.Agents.Defaults.MaxTokens, - cfg2.Agents.Defaults.MaxTokens, - ) + t.Errorf("MaxTokens changed from %d to %d", cfg1.Agents.Defaults.MaxTokens, cfg2.Agents.Defaults.MaxTokens) } } @@ -580,11 +557,7 @@ func TestMigration_Integration_ModelNameField(t *testing.T) { // GetModelName() should return model_name, not model (deprecated) if cfg.Agents.Defaults.GetModelName() != "deepseek-reasoner" { - t.Errorf( - "GetModelName() = %q, want %q", - cfg.Agents.Defaults.GetModelName(), - "deepseek-reasoner", - ) + t.Errorf("GetModelName() = %q, want %q", cfg.Agents.Defaults.GetModelName(), "deepseek-reasoner") } if len(cfg.Agents.Defaults.ModelFallbacks) != 1 { diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index 1ae3c7b71..aeabe9730 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -91,11 +91,9 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) { func TestConvertProvidersToModelList_Multiple(t *testing.T) { cfg := &configV0{ Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{ - providerConfigV0: providerConfigV0{APIKey: "openai-key"}, - }, - Groq: providerConfigV0{APIKey: "groq-key"}, - Zhipu: providerConfigV0{APIKey: "zhipu-key"}, + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, + Groq: providerConfigV0{APIKey: "groq-key"}, + Zhipu: providerConfigV0{APIKey: "zhipu-key"}, }, } @@ -144,13 +142,8 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { // Other providers have no configuration, so they won't be converted. cfg := &configV0{ Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{ - providerConfigV0: providerConfigV0{APIKey: "key1"}, - }, - LiteLLM: providerConfigV0{ - APIKey: "key-litellm", - APIBase: "http://localhost:4000/v1", - }, + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}}, + LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, Anthropic: providerConfigV0{APIKey: "key2"}, OpenRouter: providerConfigV0{APIKey: "key3"}, Groq: providerConfigV0{APIKey: "key4"}, @@ -268,11 +261,7 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) { // Should use user's model, not default if result[0].Model != "deepseek/deepseek-reasoner" { - t.Errorf( - "Model = %q, want %q (user's configured model)", - result[0].Model, - "deepseek/deepseek-reasoner", - ) + t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner") } } @@ -382,9 +371,7 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes }, }, Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{ - providerConfigV0: providerConfigV0{APIKey: "sk-openai"}, - }, + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}}, DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, }, } @@ -404,11 +391,7 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes } case "deepseek": if mc.Model != "deepseek/deepseek-reasoner" { - t.Errorf( - "DeepSeek Model = %q, want %q (user's)", - mc.Model, - "deepseek/deepseek-reasoner", - ) + t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner") } } } @@ -506,11 +489,7 @@ func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T // ModelName should be the user's model value for backward compatibility if result[0].ModelName != "glm-4.7" { - t.Errorf( - "ModelName = %q, want %q (user's model for backward compatibility)", - result[0].ModelName, - "glm-4.7", - ) + t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7") } // Model should use the user's model with protocol prefix @@ -531,10 +510,8 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin }, }, Providers: providersConfigV0{ - OpenAI: openAIProviderConfigV0{ - providerConfigV0: providerConfigV0{APIKey: "openai-key"}, - }, - Zhipu: providerConfigV0{APIKey: "zhipu-key"}, + OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, + Zhipu: providerConfigV0{APIKey: "zhipu-key"}, }, } @@ -594,11 +571,7 @@ func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) { result := buildModelWithProtocol("openrouter", "openrouter/auto") if result != "openrouter/auto" { - t.Errorf( - "buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", - result, - "openrouter/auto", - ) + t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto") } } @@ -640,10 +613,6 @@ func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) // Model should NOT have duplicated prefix if result[0].Model != "openrouter/auto" { - t.Errorf( - "Model = %q, want %q (should not duplicate prefix)", - result[0].Model, - "openrouter/auto", - ) + t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto") } } diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index f001885af..6e88f4783 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -17,11 +17,7 @@ func TestGetModelConfig_Found(t *testing.T) { Version: CurrentVersion, ModelList: []*ModelConfig{ {ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")}, - { - ModelName: "other-model", - Model: "anthropic/claude", - APIKeys: SimpleSecureStrings("key2"), - }, + {ModelName: "other-model", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("key2")}, }, } @@ -118,16 +114,8 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { func TestGetModelConfig_Concurrent(t *testing.T) { cfg := &Config{ ModelList: []*ModelConfig{ - { - ModelName: "concurrent-model", - Model: "openai/gpt-4o-1", - APIKeys: SimpleSecureStrings("key1"), - }, - { - ModelName: "concurrent-model", - Model: "openai/gpt-4o-2", - APIKeys: SimpleSecureStrings("key2"), - }, + {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, }, } @@ -302,11 +290,7 @@ func TestConfig_ValidateModelList(t *testing.T) { } if err != nil && tt.errMsg != "" { if !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf( - "ValidateModelList() error = %v, want error containing %q", - err, - tt.errMsg, - ) + t.Errorf("ValidateModelList() error = %v, want error containing %q", err, tt.errMsg) } } }) diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index 28fd9ff7d..e58c6dc9e 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -117,10 +117,7 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { ModelName: "gpt-4", Model: "openai/gpt-4o", } - modelCfg.APIKeys = SimpleSecureStrings( - "key0", - "key1", - ) // Use internal field for multi-key testing + modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing modelCfg.Fallbacks = []string{"claude-3"} models := []*ModelConfig{modelCfg} @@ -199,10 +196,7 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { RequestTimeout: 30, ThinkingLevel: "high", } - modelCfg.APIKeys = SimpleSecureStrings( - "key0", - "key1", - ) // Use internal field for multi-key testing + modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing models := []*ModelConfig{modelCfg} result := expandMultiKeyModels(models) diff --git a/pkg/config/security.go b/pkg/config/security.go index c31e877c2..79dd26e14 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -304,13 +304,11 @@ func (s *SecureString) UnmarshalJSON(value []byte) error { func (s SecureString) MarshalYAML() (any, error) { // Preserve raw value if it is already a reference (enc:// or file://) - if strings.HasPrefix(s.raw, credential.EncScheme) || - strings.HasPrefix(s.raw, credential.FileScheme) { + if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) { return s.raw, nil } // If resolved is a reference format (e.g. set via Set), copy back to raw - if strings.HasPrefix(s.resolved, credential.EncScheme) || - strings.HasPrefix(s.resolved, credential.FileScheme) { + if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) { s.raw = s.resolved return s.raw, nil } diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 5f0b61970..6ca8637f4 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -35,10 +35,7 @@ func TestJSONUnmarshalPrivateFields(t *testing.T) { t.Errorf("PublicField = %q, want 'pub'", s.PublicField) } if s.privateField != "" { - t.Errorf( - "privateField = %q, want empty because unexported fields are ignored", - s.privateField, - ) + t.Errorf("privateField = %q, want empty because unexported fields are ignored", s.privateField) } } @@ -355,21 +352,13 @@ skills: // Verify Channel tokens via Key() methods // Telegram - assert.Equal( - t, - "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", - cfg.Channels.Telegram.Token.String(), - ) + assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token.String()) t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String()) // Feishu assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String()) assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey.String()) - assert.Equal( - t, - "feishu_test_verification_token", - cfg.Channels.Feishu.VerificationToken.String(), - ) + assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken.String()) t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String()) t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String()) t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String()) @@ -394,11 +383,7 @@ skills: // LINE assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String()) - assert.Equal( - t, - "line_test_channel_access_token", - cfg.Channels.LINE.ChannelAccessToken.String(), - ) + assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken.String()) t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String()) t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.String()) @@ -446,11 +431,7 @@ skills: assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token.String()) t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token.String()) - assert.Equal( - t, - "clawhub-auth-token-from-file", - cfg.Tools.Skills.Registries.ClawHub.AuthToken.String(), - ) + assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String()) t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String()) t.Log("All security keys are successfully accessible via their respective Key() methods") diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index e205d7cf3..60d9d5e5a 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -15,10 +15,7 @@ import ( // JobExecutor is the interface for executing cron jobs through the agent type JobExecutor interface { - ProcessDirectWithChannel( - ctx context.Context, - content, sessionKey, channel, chatID string, - ) (string, error) + ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) // PublishResponseIfNeeded sends response to the outbound bus only when the // agent did not already deliver content through the message tool in this round. PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) @@ -37,13 +34,8 @@ type CronTool struct { // NewCronTool creates a new CronTool // execTimeout: 0 means no timeout, >0 sets the timeout duration func NewCronTool( - cronService *cron.CronService, - executor JobExecutor, - msgBus *bus.MessageBus, - workspace string, - restrict bool, - execTimeout time.Duration, - config *config.Config, + cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, + execTimeout time.Duration, config *config.Config, ) (*CronTool, error) { allowCommand := true execEnabled := true @@ -164,9 +156,7 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult chatID := ToolChatID(ctx) if channel == "" || chatID == "" { - return ErrorResult( - "no session context (channel/chat_id not set). Use this tool in an active conversation.", - ) + return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.") } message, ok := args["message"].(string) @@ -218,9 +208,7 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult // Validate type parameter (server-side whitelist, not just LLM schema hint) msgType, _ := args["type"].(string) if msgType != "" && msgType != "message" && msgType != "directive" { - return ErrorResult( - fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType), - ) + return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType)) } // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go index 251db5803..186c6a75e 100644 --- a/pkg/tools/cron_test.go +++ b/pkg/tools/cron_test.go @@ -49,11 +49,7 @@ func (s *stubJobExecutor) PublishResponseIfNeeded( s.publishedChatID = chatID } -func newTestCronToolWithExecutorAndConfig( - t *testing.T, - executor JobExecutor, - cfg *config.Config, -) *CronTool { +func newTestCronToolWithExecutorAndConfig(t *testing.T, executor JobExecutor, cfg *config.Config) *CronTool { t.Helper() storePath := filepath.Join(t.TempDir(), "cron.json") cronService := cron.NewCronService(storePath, nil) @@ -106,10 +102,7 @@ func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) { }) if result.IsError { - t.Fatalf( - "expected command scheduling without confirm to succeed by default, got: %s", - result.ForLLM, - ) + t.Fatalf("expected command scheduling without confirm to succeed by default, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) @@ -197,10 +190,7 @@ func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) { }) if result.IsError { - t.Fatalf( - "expected command scheduling to succeed from internal channel, got: %s", - result.ForLLM, - ) + t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "Cron job added") { t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) @@ -235,10 +225,7 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { }) if result.IsError { - t.Fatalf( - "expected non-command reminder to succeed from remote channel, got: %s", - result.ForLLM, - ) + t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM) } } @@ -310,11 +297,7 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey) } if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" { - t.Fatalf( - "executor target = %s/%s, want telegram/chat-1", - executor.lastChan, - executor.lastChatID, - ) + t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID) } if executor.lastPrompt != "send me a poem" { t.Fatalf("prompt = %q, want original message", executor.lastPrompt) @@ -323,11 +306,7 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) { t.Fatalf("published response = %q, want generated reply", executor.publishedResp) } if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { - t.Fatalf( - "published target = %s/%s, want telegram/chat-1", - executor.publishedChan, - executor.publishedChatID, - ) + t.Fatalf("published target = %s/%s, want telegram/chat-1", executor.publishedChan, executor.publishedChatID) } } @@ -363,10 +342,7 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) { } if executor.publishedResp != "" { - t.Fatalf( - "expected no published response when message tool already sent, got: %q", - executor.publishedResp, - ) + t.Fatalf("expected no published response when message tool already sent, got: %q", executor.publishedResp) } } @@ -410,9 +386,7 @@ func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) { } if executor.lastPrompt == "" { - t.Fatal( - "expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked", - ) + t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked") } if executor.publishedResp != "agent processed" { t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed") diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 78fc512c6..d5bebf4a2 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -16,11 +16,7 @@ type EditFileTool struct { } // NewEditFileTool creates a new EditFileTool with optional directory restriction. -func NewEditFileTool( - workspace string, - restrict bool, - allowPaths ...[]*regexp.Regexp, -) *EditFileTool { +func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -83,11 +79,7 @@ type AppendFileTool struct { fs fileSystem } -func NewAppendFileTool( - workspace string, - restrict bool, - allowPaths ...[]*regexp.Regexp, -) *AppendFileTool { +func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -174,10 +166,7 @@ func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) count := strings.Count(contentStr, oldText) if count > 1 { - return nil, fmt.Errorf( - "old_text appears %d times. Please provide more context to make it unique", - count, - ) + return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) } newContent := strings.Replace(contentStr, oldText, newText, 1) diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go index 25f89fb88..83a7e778c 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/edit_test.go @@ -76,8 +76,7 @@ func TestEditTool_EditFile_NotFound(t *testing.T) { } // Should mention file not found - if !strings.Contains(result.ForLLM, "not found") && - !strings.Contains(result.ForUser, "not found") { + if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM) } } @@ -104,8 +103,7 @@ func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { } // Should mention old_text not found - if !strings.Contains(result.ForLLM, "not found") && - !strings.Contains(result.ForUser, "not found") { + if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM) } } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 35da9ecde..39d45013d 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -20,11 +20,7 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow -func validatePathWithAllowPaths( - path, workspace string, - restrict bool, - patterns []*regexp.Regexp, -) (string, error) { +func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) { if workspace == "" { return path, fmt.Errorf("workspace is not defined") } @@ -487,11 +483,7 @@ type WriteFileTool struct { fs fileSystem } -func NewWriteFileTool( - workspace string, - restrict bool, - allowPaths ...[]*regexp.Regexp, -) *WriteFileTool { +func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -544,9 +536,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR if !overwrite { if _, err := t.fs.Open(path); err == nil { - return ErrorResult( - fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path), - ) + return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path)) } } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 90b20b47e..0b4dd310b 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -59,13 +59,8 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to open file") && - !strings.Contains(result.ForUser, "failed to read") { - t.Errorf( - "Expected error message, got ForLLM: %s, ForUser: %s", - result.ForLLM, - result.ForUser, - ) + if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { + t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } @@ -83,8 +78,7 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { } // Should mention required parameter - if !strings.Contains(result.ForLLM, "path is required") && - !strings.Contains(result.ForUser, "path is required") { + if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) } } @@ -303,12 +297,7 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) { "content": "replaced in sandbox", "overwrite": true, }) - assert.False( - t, - result.IsError, - "expected success in sandbox mode with overwrite=true, got: %s", - result.ForLLM, - ) + assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM) data, err := os.ReadFile(filepath.Join(workspace, testFile)) assert.NoError(t, err) @@ -336,8 +325,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { } // Should list files and directories - if !strings.Contains(result.ForLLM, "file1.txt") || - !strings.Contains(result.ForLLM, "file2.txt") { + if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { t.Errorf("Expected files in listing, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subdir") { @@ -361,13 +349,8 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && - !strings.Contains(result.ForUser, "failed to read") { - t.Errorf( - "Expected error message, got ForLLM: %s, ForUser: %s", - result.ForLLM, - result.ForUser, - ) + if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { + t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } @@ -414,8 +397,7 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { // os.Root might return different errors depending on platform/implementation // but it definitely should error. // Our wrapper returns "access denied or file not found" - if !strings.Contains(result.ForLLM, "access denied") && - !strings.Contains(result.ForLLM, "file not found") && + if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && !strings.Contains(result.ForLLM, "no such file") { t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) } @@ -434,20 +416,10 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { }) // We EXPECT IsError=true (access blocked due to empty workspace) - assert.True( - t, - result.IsError, - "Security Regression: Empty workspace allowed access! content: %s", - result.ForLLM, - ) + assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) // Verify it failed for the right reason - assert.Contains( - t, - result.ForLLM, - "workspace is not defined", - "Expected 'workspace is not defined' error", - ) + assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") } // TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: @@ -681,10 +653,7 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) - result := tool.Execute( - context.Background(), - map[string]any{"path": filepath.Join(linkPath, "secret.txt")}, - ) + result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")}) if !result.IsError { t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) } diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go index e3d5c152c..779b1d5a7 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/i2c.go @@ -65,9 +65,7 @@ func (t *I2CTool) Parameters() map[string]any { func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { - return ErrorResult( - "I2C is only supported on Linux. This tool requires /dev/i2c-* device files.", - ) + return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.") } action, ok := args["action"].(string) @@ -85,9 +83,7 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult case "write": return t.writeDevice(args) default: - return ErrorResult( - fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action), - ) + return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) } } diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index ccd57b24b..4eaaf8f09 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -55,12 +55,7 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool { size: i2cSmbusQuick, data: nil, } - _, _, errno := syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - i2cSmbus, - uintptr(unsafe.Pointer(&args)), - ) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) return errno == 0 } @@ -72,12 +67,7 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool { size: i2cSmbusByte, data: &data, } - _, _, errno := syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - i2cSmbus, - uintptr(unsafe.Pointer(&args)), - ) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args))) return errno == 0 } @@ -93,29 +83,16 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { devPath := fmt.Sprintf("/dev/i2c-%s", bus) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { - return ErrorResult( - fmt.Sprintf( - "failed to open %s: %v (check permissions and i2c-dev module)", - devPath, - err, - ), - ) + return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err)) } defer syscall.Close(fd) // Query adapter capabilities to determine available probe methods. // I2C_FUNCS writes an unsigned long, which is word-sized on Linux. var funcs uintptr - _, _, errno := syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - i2cFuncs, - uintptr(unsafe.Pointer(&funcs)), - ) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs))) if errno != 0 { - return ErrorResult( - fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno), - ) + return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno)) } hasQuick := funcs&i2cFuncSmbusQuick != 0 @@ -123,10 +100,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { if !hasQuick && !hasReadByte { return ErrorResult( - fmt.Sprintf( - "I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", - devPath, - ), + fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath), ) } @@ -158,9 +132,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { } if len(found) == 0 { - return SilentResult( - fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath), - ) + return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath)) } result, _ := json.MarshalIndent(map[string]any{ diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go index d4674d376..5bffb4e89 100644 --- a/pkg/tools/mcp_tool.go +++ b/pkg/tools/mcp_tool.go @@ -314,10 +314,7 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont return result } -func (t *MCPTool) storeEmbeddedResource( - ctx context.Context, - content *mcp.EmbeddedResource, -) (string, string) { +func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) { if content == nil || content.Resource == nil { return "", "[MCP returned an embedded resource without data.]" } @@ -377,39 +374,23 @@ func (t *MCPTool) storeBinaryContent( dir := media.TempDir() if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Sprintf( - "[MCP returned %s content (%s) but it could not be stored.]", - kind, - mimeType, - ) + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) } ext := extensionForMIMEType(mimeType) tmpFile, err := os.CreateTemp(dir, "mcp-*"+ext) if err != nil { - return "", fmt.Sprintf( - "[MCP returned %s content (%s) but it could not be stored.]", - kind, - mimeType, - ) + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) } tmpPath := tmpFile.Name() if _, err = tmpFile.Write(data); err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) - return "", fmt.Sprintf( - "[MCP returned %s content (%s) but it could not be stored.]", - kind, - mimeType, - ) + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) } if err = tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) - return "", fmt.Sprintf( - "[MCP returned %s content (%s) but it could not be stored.]", - kind, - mimeType, - ) + return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType) } scope := fmt.Sprintf( @@ -489,10 +470,7 @@ func summarizeEmbeddedResource(content *mcp.EmbeddedResource) string { normalizedMIMEType(resource.MIMEType), ) } - return fmt.Sprintf( - "[MCP returned embedded resource (%s).]", - normalizedMIMEType(resource.MIMEType), - ) + return fmt.Sprintf("[MCP returned embedded resource (%s).]", normalizedMIMEType(resource.MIMEType)) } func annotationsAllowUser(annotations *mcp.Annotations) bool { diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go index 3b514cd82..8bbac3bc7 100644 --- a/pkg/tools/mcp_tool_test.go +++ b/pkg/tools/mcp_tool_test.go @@ -571,10 +571,7 @@ func TestMCPTool_Execute_EmbeddedResourceBlobStoredAsMedia(t *testing.T) { result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil) if len(result.Media) != 1 { - t.Fatalf( - "expected embedded resource blob to be stored as media, got %d refs", - len(result.Media), - ) + t.Fatalf("expected embedded resource blob to be stored as media, got %d refs", len(result.Media)) } path, _, err := store.ResolveWithMeta(result.Media[0]) if err != nil { diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 1b8bfab4a..05630972e 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -43,10 +43,7 @@ func TestMessageTool_Execute_Success(t *testing.T) { // - ForLLM contains send status description if result.ForLLM != "Message sent to test-channel:test-chat-id" { - t.Errorf( - "Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", - result.ForLLM, - ) + t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM) } // - ForUser is empty (user already received message directly) @@ -91,10 +88,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { t.Error("Expected Silent=true") } if result.ForLLM != "Message sent to custom-channel:custom-chat-id" { - t.Errorf( - "Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", - result.ForLLM, - ) + t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM) } } diff --git a/pkg/tools/normalization.go b/pkg/tools/normalization.go index 9cd9c65c6..3a76c5d92 100644 --- a/pkg/tools/normalization.go +++ b/pkg/tools/normalization.go @@ -215,43 +215,28 @@ func storeInlineDataURL( payload = strings.NewReplacer("\n", "", "\r", "", "\t", "", " ", "").Replace(payload) decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { - return "", fmt.Sprintf( - "[Tool returned inline media content (%s) that could not be decoded.]", - mimeType, - ) + return "", fmt.Sprintf("[Tool returned inline media content (%s) that could not be decoded.]", mimeType) } dir := media.TempDir() if err = os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Sprintf( - "[Tool returned inline media content (%s) but it could not be stored.]", - mimeType, - ) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) } ext := extensionForMIMEType(mimeType) tmpFile, err := os.CreateTemp(dir, "tool-inline-*"+ext) if err != nil { - return "", fmt.Sprintf( - "[Tool returned inline media content (%s) but it could not be stored.]", - mimeType, - ) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) } tmpPath := tmpFile.Name() if _, err = tmpFile.Write(decoded); err != nil { tmpFile.Close() _ = os.Remove(tmpPath) - return "", fmt.Sprintf( - "[Tool returned inline media content (%s) but it could not be stored.]", - mimeType, - ) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) } if err = tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) - return "", fmt.Sprintf( - "[Tool returned inline media content (%s) but it could not be stored.]", - mimeType, - ) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType) } filename := sanitizeIdentifierComponent(toolName) + ext @@ -270,10 +255,7 @@ func storeInlineDataURL( }, scope) if err != nil { _ = os.Remove(tmpPath) - return "", fmt.Sprintf( - "[Tool returned inline media content (%s) but it could not be registered.]", - mimeType, - ) + return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be registered.]", mimeType) } return ref, fmt.Sprintf(inlineMediaStoredMessage, mimeType) diff --git a/pkg/tools/result.go b/pkg/tools/result.go index 1976eb88b..c81213125 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/result.go @@ -80,10 +80,7 @@ func (tr *ToolResult) ContentForLLM() string { } } if len(tr.ArtifactTags) > 0 { - artifactNote := "Local artifact paths: " + strings.Join( - tr.ArtifactTags, - " ", - ) + "\n" + artifactPathsLLMNote + artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote if content == "" { content = artifactNote } else if !strings.Contains(content, artifactNote) { diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go index 87b2f1b4b..5f08cb4fa 100644 --- a/pkg/tools/result_test.go +++ b/pkg/tools/result_test.go @@ -142,11 +142,7 @@ func TestToolResultJSONSerialization(t *testing.T) { t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM) } if decoded.ForUser != tt.result.ForUser { - t.Errorf( - "ForUser mismatch: got '%s', want '%s'", - decoded.ForUser, - tt.result.ForUser, - ) + t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser) } if decoded.Silent != tt.result.Silent { t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent) diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go index 21326504d..f41c80d90 100644 --- a/pkg/tools/search_tool.go +++ b/pkg/tools/search_tool.go @@ -56,38 +56,19 @@ func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *Too } if len(pattern) > MaxRegexPatternLength { - logger.WarnCF( - "discovery", - "Regex pattern rejected (too long)", - map[string]any{"len": len(pattern)}, - ) - return ErrorResult( - fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength), - ) + logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)}) + return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength)) } logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern}) res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) if err != nil { - logger.WarnCF( - "discovery", - "Invalid regex pattern", - map[string]any{"pattern": pattern, "error": err.Error()}, - ) - return ErrorResult( - fmt.Sprintf( - "Invalid regex pattern syntax: %v. Please fix your regex and try again.", - err, - ), - ) + logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()}) + return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) } - logger.InfoCF( - "discovery", - "Regex search completed", - map[string]any{"pattern": pattern, "results": len(res)}, - ) + logger.InfoCF("discovery", "Regex search completed", map[string]any{"pattern": pattern, "results": len(res)}) return formatDiscoveryResponse(t.registry, res, t.ttl) } @@ -157,11 +138,7 @@ func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *Tool } } - logger.InfoCF( - "discovery", - "BM25 search completed", - map[string]any{"query": query, "results": len(results)}, - ) + logger.InfoCF("discovery", "BM25 search completed", map[string]any{"query": query, "results": len(results)}) return formatDiscoveryResponse(t.registry, results, t.ttl) } @@ -173,10 +150,7 @@ type ToolSearchResult struct { Description string `json:"description"` } -func (r *ToolRegistry) SearchRegex( - pattern string, - maxSearchResults int, -) ([]ToolSearchResult, error) { +func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) { if maxSearchResults <= 0 { return nil, nil } @@ -214,11 +188,7 @@ func (r *ToolRegistry) SearchRegex( return results, nil } -func formatDiscoveryResponse( - registry *ToolRegistry, - results []ToolSearchResult, - ttl int, -) *ToolResult { +func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult { if len(results) == 0 { return SilentResult("No tools found matching the query.") } @@ -304,11 +274,7 @@ func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine { cached := &bm25CachedEngine{engine: buildBM25Engine(docs)} t.cachedEngine = cached t.cacheVersion = snap.Version - logger.DebugCF( - "discovery", - "BM25 engine rebuilt", - map[string]any{"docs": len(docs), "version": snap.Version}, - ) + logger.DebugCF("discovery", "BM25 engine rebuilt", map[string]any{"docs": len(docs), "version": snap.Version}) return cached } diff --git a/pkg/tools/search_tools_test.go b/pkg/tools/search_tools_test.go index 72cb11444..3aae941cb 100644 --- a/pkg/tools/search_tools_test.go +++ b/pkg/tools/search_tools_test.go @@ -93,10 +93,7 @@ func TestRegexSearchTool_Execute(t *testing.T) { reg.mu.RLock() defer reg.mu.RUnlock() if reg.tools["mcp_read_file"].TTL != 5 { - t.Errorf( - "Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", - reg.tools["mcp_read_file"].TTL, - ) + t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", reg.tools["mcp_read_file"].TTL) } if reg.tools["mcp_fetch_net"].TTL != 0 { t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)") diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go index a344f4b5c..44198381e 100644 --- a/pkg/tools/send_file.go +++ b/pkg/tools/send_file.go @@ -142,10 +142,7 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult(fmt.Sprintf("failed to register media: %v", err)) } - return MediaResult( - fmt.Sprintf("File %q sent to user", filename), - []string{ref}, - ).WithResponseHandled() + return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}).WithResponseHandled() } // detectMediaType determines the MIME type of a file. diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go index 26b3c17ab..f36baf7d0 100644 --- a/pkg/tools/send_file_test.go +++ b/pkg/tools/send_file_test.go @@ -79,11 +79,7 @@ func TestSendFileTool_FileTooLarge(t *testing.T) { func TestSendFileTool_DefaultMaxSize(t *testing.T) { tool := NewSendFileTool("/tmp", false, 0, nil) if tool.maxFileSize != config.DefaultMaxMediaSize { - t.Errorf( - "expected default max size %d, got %d", - config.DefaultMaxMediaSize, - tool.maxFileSize, - ) + t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize) } } @@ -166,11 +162,7 @@ func TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) { t.Cleanup(func() { _ = os.Remove(testPath) }) pattern := regexp.MustCompile( - "^" + regexp.QuoteMeta( - filepath.Clean(mediaDir), - ) + "(?:" + regexp.QuoteMeta( - string(os.PathSeparator), - ) + "|$)", + "^" + regexp.QuoteMeta(filepath.Clean(mediaDir)) + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)", ) store := media.NewFileMediaStore() diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 0d1c4c5db..6ee1cb993 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -113,11 +113,7 @@ var ( } ) -func NewExecTool( - workingDir string, - restrict bool, - allowPaths ...[]*regexp.Regexp, -) (*ExecTool, error) { +func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) { return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...) } @@ -197,16 +193,8 @@ func (t *ExecTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "action": map[string]any{ - "type": "string", - "enum": []string{ - "run", - "list", - "poll", - "read", - "write", - "kill", - "send-keys", - }, + "type": "string", + "enum": []string{"run", "list", "poll", "read", "write", "kill", "send-keys"}, "description": "Action: run (execute command), list (show sessions), poll (check status), read (get output), write (send input), kill (terminate), send-keys (send keys to PTY)", }, "command": map[string]any{ @@ -312,12 +300,7 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes cwd := t.workingDir if wd, ok := args["cwd"].(string); ok && wd != "" { if t.restrictToWorkspace && t.workingDir != "" { - resolvedWD, err := validatePathWithAllowPaths( - wd, - t.workingDir, - true, - t.allowedPathPatterns, - ) + resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns) if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } @@ -343,9 +326,7 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir { resolved, err := filepath.EvalSymlinks(cwd) if err != nil { - return ErrorResult( - fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err), - ) + return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err)) } if isAllowedPath(resolved, t.allowedPathPatterns) { cwd = resolved @@ -383,14 +364,7 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult var cmd *exec.Cmd if runtime.GOOS == "windows" { - cmd = exec.CommandContext( - cmdCtx, - "powershell", - "-NoProfile", - "-NonInteractive", - "-Command", - command, - ) + cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) } else { cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) } @@ -468,10 +442,7 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult maxLen := 10000 if len(output) > maxLen { - output = output[:maxLen] + fmt.Sprintf( - "\n... (truncated, %d more chars)", - len(output)-maxLen, - ) + output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) } if err != nil { @@ -489,11 +460,7 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult } } -func (t *ExecTool) runBackground( - ctx context.Context, - command, cwd string, - ptyEnabled bool, -) *ToolResult { +func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult { sessionID := generateSessionID() session := &ProcessSession{ ID: sessionID, @@ -586,8 +553,7 @@ func (t *ExecTool) runBackground( n, err := session.ptyMaster.Read(buf) if n > 0 { raw := string(buf[:n]) - if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && - mode != session.GetPtyKeyMode() { + if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() { session.SetPtyKeyMode(mode) } @@ -768,16 +734,12 @@ func (t *ExecTool) executeWrite(args map[string]any) *ToolResult { } if session.IsDone() { - return ErrorResult( - fmt.Sprintf("process already exited with code %d", session.GetExitCode()), - ) + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) } if err := session.Write(data); err != nil { if errors.Is(err, ErrSessionDone) { - return ErrorResult( - fmt.Sprintf("process already exited with code %d", session.GetExitCode()), - ) + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) } return ErrorResult(fmt.Sprintf("failed to write to session: %v", err)) } @@ -808,9 +770,7 @@ func (t *ExecTool) executeKill(args map[string]any) *ToolResult { } if session.IsDone() { - return ErrorResult( - fmt.Sprintf("process already exited with code %d", session.GetExitCode()), - ) + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) } if err := session.Kill(); err != nil { @@ -1032,16 +992,12 @@ func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult { } if session.IsDone() { - return ErrorResult( - fmt.Sprintf("process already exited with code %d", session.GetExitCode()), - ) + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) } if err := session.Write(data); err != nil { if errors.Is(err, ErrSessionDone) { - return ErrorResult( - fmt.Sprintf("process already exited with code %d", session.GetExitCode()), - ) + return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode())) } return ErrorResult(fmt.Sprintf("failed to send keys: %v", err)) } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 228ec1067..a8de2f4c9 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -100,13 +100,8 @@ func TestShellTool_Timeout(t *testing.T) { } // Should mention timeout - if !strings.Contains(result.ForLLM, "timed out") && - !strings.Contains(result.ForUser, "timed out") { - t.Errorf( - "Expected timeout message, got ForLLM: %s, ForUser: %s", - result.ForLLM, - result.ForUser, - ) + if !strings.Contains(result.ForLLM, "timed out") && !strings.Contains(result.ForUser, "timed out") { + t.Errorf("Expected timeout message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } @@ -161,11 +156,7 @@ func TestShellTool_DangerousCommand(t *testing.T) { } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { - t.Errorf( - "Expected 'blocked' message, got ForLLM: %s, ForUser: %s", - result.ForLLM, - result.ForUser, - ) + t.Errorf("Expected 'blocked' message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } @@ -186,11 +177,7 @@ func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { t.Errorf("Expected kill command to be blocked") } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { - t.Errorf( - "Expected blocked message, got ForLLM: %s, ForUser: %s", - result.ForLLM, - result.ForUser, - ) + t.Errorf("Expected blocked message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } @@ -282,10 +269,7 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { }) if !result.IsError { - t.Fatalf( - "expected working_dir outside workspace to be blocked, got output: %s", - result.ForLLM, - ) + t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "blocked") { t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) @@ -460,10 +444,7 @@ func TestShellTool_DevNullAllowed(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute( - context.Background(), - map[string]any{"action": "run", "command": cmd}, - ) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "blocked") { t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM) } @@ -492,10 +473,7 @@ func TestShellTool_BlockDevices(t *testing.T) { } for _, cmd := range blocked { - result := tool.Execute( - context.Background(), - map[string]any{"action": "run", "command": cmd}, - ) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if !result.IsError { t.Errorf("expected block device write to be blocked: %s", cmd) } @@ -519,16 +497,9 @@ func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) { } for _, cmd := range commands { - result := tool.Execute( - context.Background(), - map[string]any{"action": "run", "command": cmd}, - ) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { - t.Errorf( - "safe path should not be blocked by workspace check: %s\n error: %s", - cmd, - result.ForLLM, - ) + t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } } } @@ -620,10 +591,7 @@ func TestShellTool_CustomAllowPatterns(t *testing.T) { "command": "git push origin main", }) if result.IsError && strings.Contains(result.ForLLM, "blocked") { - t.Errorf( - "custom allow pattern should exempt 'git push origin main', got: %s", - result.ForLLM, - ) + t.Errorf("custom allow pattern should exempt 'git push origin main', got: %s", result.ForLLM) } // "git push upstream main" should still be blocked (does not match allow pattern). @@ -661,11 +629,7 @@ func TestShellTool_URLsNotBlocked(t *testing.T) { result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd}) cancel() if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { - t.Errorf( - "command with URL should not be blocked by workspace check: %s\n error: %s", - cmd, - result.ForLLM, - ) + t.Errorf("command with URL should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } } } @@ -688,10 +652,7 @@ func TestShellTool_FileURISandboxing(t *testing.T) { } for _, cmd := range blockedCommands { - result := tool.Execute( - context.Background(), - map[string]any{"action": "run", "command": cmd}, - ) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("file:// URI outside workspace should be blocked: %s", cmd) } @@ -709,16 +670,9 @@ func TestShellTool_FileURISandboxing(t *testing.T) { } for _, cmd := range allowedCommands { - result := tool.Execute( - context.Background(), - map[string]any{"action": "run", "command": cmd}, - ) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { - t.Errorf( - "file:// URI inside workspace should be allowed: %s\n error: %s", - cmd, - result.ForLLM, - ) + t.Errorf("file:// URI inside workspace should be allowed: %s\n error: %s", cmd, result.ForLLM) } } } @@ -742,10 +696,7 @@ func TestShellTool_URLBypassPrevented(t *testing.T) { } for _, cmd := range blockedCommands { - result := tool.Execute( - context.Background(), - map[string]any{"action": "run", "command": cmd}, - ) + result := tool.Execute(context.Background(), map[string]any{"action": "run", "command": cmd}) if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") { t.Errorf("bypass attempt should be blocked: %q\n got: %s", cmd, result.ForLLM) } @@ -1270,9 +1221,7 @@ func TestShellTool_PTY_ProcessGroupKill(t *testing.T) { // The binary is created in /tmp/test_pgroup.c and compiled as part of test setup. testBinary := "/tmp/test_pgroup" if _, err := os.Stat(testBinary); os.IsNotExist(err) { - t.Skip( - "Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c", - ) + t.Skip("Test binary /tmp/test_pgroup not found - run: gcc -o /tmp/test_pgroup /tmp/test_pgroup.c") } tool, err := NewExecTool("", false) @@ -1606,16 +1555,8 @@ func TestDetectPtyKeyMode(t *testing.T) { {"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI}, {"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, {"both rmkx first", "\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, - { - "multiple toggles smkx last", - "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", - PtyKeyModeSS3, - }, - { - "multiple toggles rmkx last", - "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", - PtyKeyModeCSI, - }, + {"multiple toggles smkx last", "\x1b[?1h\x1b=...\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, + {"multiple toggles rmkx last", "\x1b[?1l\x1b>...\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, {"partial smkx", "\x1b[?1h", PtyKeyModeSS3}, {"partial rmkx", "\x1b[?1l", PtyKeyModeCSI}, } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index ffb4b0c52..71bfe730b 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -96,11 +96,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To if !force { if _, err := os.Stat(targetDir); err == nil { return ErrorResult( - fmt.Sprintf( - "skill %q already installed at %s. Use force=true to reinstall.", - slug, - targetDir, - ), + fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), ) } } else { @@ -146,9 +142,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To "error": rmErr.Error(), }) } - return ErrorResult( - fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug), - ) + return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) } // Write origin metadata. @@ -168,10 +162,7 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To // Build result with moderation warning if suspicious. var output string if result.IsSuspicious { - output = fmt.Sprintf( - "⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", - slug, - ) + output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) } output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n", slug, result.Version, registry.Name(), targetDir) diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go index 8f7401dfa..2b6cffd38 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/skills_search.go @@ -17,10 +17,7 @@ type FindSkillsTool struct { // NewFindSkillsTool creates a new FindSkillsTool. // registryMgr is the shared registry manager (built from config in createToolRegistry). // cache is the search cache for deduplicating similar queries. -func NewFindSkillsTool( - registryMgr *skills.RegistryManager, - cache *skills.SearchCache, -) *FindSkillsTool { +func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { return &FindSkillsTool{ registryMgr: registryMgr, cache: cache, diff --git a/pkg/tools/spawn_status.go b/pkg/tools/spawn_status.go index 22202d899..416fd2226 100644 --- a/pkg/tools/spawn_status.go +++ b/pkg/tools/spawn_status.go @@ -77,12 +77,10 @@ func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *Too } // Restrict lookup to tasks that belong to this conversation. - if callerChannel != "" && taskCopy.OriginChannel != "" && - taskCopy.OriginChannel != callerChannel { + if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } - if callerChatID != "" && taskCopy.OriginChatID != "" && - taskCopy.OriginChatID != callerChatID { + if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID { return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID)) } diff --git a/pkg/tools/spawn_status_test.go b/pkg/tools/spawn_status_test.go index 22b885fb7..9c772d61a 100644 --- a/pkg/tools/spawn_status_test.go +++ b/pkg/tools/spawn_status_test.go @@ -195,12 +195,7 @@ func TestSpawnStatusTool_TaskID_NonString(t *testing.T) { for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} { result := tool.Execute(context.Background(), map[string]any{"task_id": badVal}) if !result.IsError { - t.Errorf( - "Expected error for task_id=%T(%v), got success: %s", - badVal, - badVal, - result.ForLLM, - ) + t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM) } if !strings.Contains(result.ForLLM, "task_id must be a string") { t.Errorf("Expected type-error message, got: %s", result.ForLLM) @@ -324,10 +319,7 @@ func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) { t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM) } if pos2 > pos10 { - t.Errorf( - "Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", - result.ForLLM, - ) + t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM) } } diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go index cdf23db86..0ca17e84f 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/spi.go @@ -69,9 +69,7 @@ func (t *SPITool) Parameters() map[string]any { func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult { if runtime.GOOS != "linux" { - return ErrorResult( - "SPI is only supported on Linux. This tool requires /dev/spidev* device files.", - ) + return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.") } action, ok := args["action"].(string) @@ -126,9 +124,7 @@ func (t *SPITool) list() *ToolResult { // parseSPIArgs extracts and validates common SPI parameters // //nolint:unused // Used by spi_linux.go -func parseSPIArgs( - args map[string]any, -) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { +func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) if !ok || dev == "" { return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go index d03c4ef92..9def73662 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/spi_linux.go @@ -38,46 +38,25 @@ type spiTransfer struct { func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { - return -1, ErrorResult( - fmt.Sprintf( - "failed to open %s: %v (check permissions and spidev module)", - devPath, - err, - ), - ) + return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) } // Set SPI mode - _, _, errno := syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - spiIocWrMode, - uintptr(unsafe.Pointer(&mode)), - ) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode))) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno)) } // Set bits per word - _, _, errno = syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - spiIocWrBitsPerWord, - uintptr(unsafe.Pointer(&bits)), - ) + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits))) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno)) } // Set max speed - _, _, errno = syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - spiIocWrMaxSpeedHz, - uintptr(unsafe.Pointer(&speed)), - ) + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed))) if errno != 0 { syscall.Close(fd) return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno)) @@ -138,12 +117,7 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult { bitsPerWord: bits, } - _, _, errno := syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - spiIocMessage1, - uintptr(unsafe.Pointer(&xfer)), - ) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) runtime.KeepAlive(txBuf) runtime.KeepAlive(rxBuf) if errno != 0 { @@ -200,12 +174,7 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult { bitsPerWord: bits, } - _, _, errno := syscall.Syscall( - syscall.SYS_IOCTL, - uintptr(fd), - spiIocMessage1, - uintptr(unsafe.Pointer(&xfer)), - ) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer))) runtime.KeepAlive(txBuf) runtime.KeepAlive(rxBuf) if errno != 0 { diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index 601d3f937..89ac7d4b5 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -316,11 +316,7 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) { // ForUser should be truncated to 500 chars + "..." maxUserLen := 500 if len(result.ForUser) > maxUserLen+3 { // +3 for "..." - t.Errorf( - "ForUser should be truncated to ~%d chars, got: %d", - maxUserLen, - len(result.ForUser), - ) + t.Errorf("ForUser should be truncated to ~%d chars, got: %d", maxUserLen, len(result.ForUser)) } // ForLLM should have full content diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index df72301a2..387813e94 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -64,13 +64,7 @@ func RunToolLoop( llmOpts = map[string]any{} } // 3. Call LLM - response, err := config.Provider.Chat( - ctx, - messages, - providerToolDefs, - config.Model, - llmOpts, - ) + response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", map[string]any{ @@ -154,14 +148,7 @@ func RunToolLoop( var toolResult *ToolResult if config.Tools != nil { - toolResult = config.Tools.ExecuteWithContext( - ctx, - tc.Name, - tc.Arguments, - channel, - chatID, - nil, - ) + toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) } else { toolResult = ErrorResult("No tools available") } diff --git a/pkg/tools/validate_test.go b/pkg/tools/validate_test.go index accff9e3c..e7f4f619a 100644 --- a/pkg/tools/validate_test.go +++ b/pkg/tools/validate_test.go @@ -151,10 +151,7 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{ - "type": "string", - "enum": []any{"red", "green", "blue"}, - }, + "color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}}, }, }, args: map[string]any{"color": "red"}, @@ -164,10 +161,7 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{ - "type": "string", - "enum": []any{"red", "green", "blue"}, - }, + "color": map[string]any{"type": "string", "enum": []any{"red", "green", "blue"}}, }, }, args: map[string]any{"color": "yellow"}, @@ -178,10 +172,7 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{ - "type": "string", - "enum": []string{"red", "green", "blue"}, - }, + "color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}}, }, }, args: map[string]any{"color": "green"}, @@ -191,10 +182,7 @@ func TestValidateToolArgs(t *testing.T) { schema: map[string]any{ "type": "object", "properties": map[string]any{ - "color": map[string]any{ - "type": "string", - "enum": []string{"red", "green", "blue"}, - }, + "color": map[string]any{"type": "string", "enum": []string{"red", "green", "blue"}}, }, }, args: map[string]any{"color": "yellow"}, @@ -354,11 +342,7 @@ func TestValidateToolArgs_RegistryIntegration(t *testing.T) { } // Extra property — should fail with validation error - result = r.Execute( - context.Background(), - "read_file", - map[string]any{"path": "/x", "__inject": true}, - ) + result = r.Execute(context.Background(), "read_file", map[string]any{"path": "/x", "__inject": true}) if !result.IsError { t.Error("expected validation error for extra property") } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 2c0de25f7..de6187cfa 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -54,8 +54,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) { } // ForUser should contain summary - if !strings.Contains(result.ForUser, "bytes") && - !strings.Contains(result.ForUser, "extractor") { + if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) } } @@ -76,11 +75,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() @@ -105,11 +100,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { func TestWebTool_WebFetch_InvalidURL(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() @@ -134,11 +125,7 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) { func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() @@ -154,8 +141,7 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { } // Should mention only http/https allowed - if !strings.Contains(result.ForLLM, "http/https") && - !strings.Contains(result.ForUser, "http/https") { + if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) } } @@ -164,11 +150,7 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { func TestWebTool_WebFetch_MissingURL(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() @@ -182,8 +164,7 @@ func TestWebTool_WebFetch_MissingURL(t *testing.T) { } // Should mention URL is required - if !strings.Contains(result.ForLLM, "url is required") && - !strings.Contains(result.ForUser, "url is required") { + if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) } } @@ -203,11 +184,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { tool, err := NewWebFetchTool(1000, format, testFetchLimit) // Limit to 1000 chars if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() @@ -239,10 +216,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { // Text should end with the truncation notice if text, ok := resultMap["text"].(string); ok { if !strings.HasSuffix(text, "[Content truncated due to size limit]") { - t.Errorf( - "Expected text to end with truncation notice, got: %q", - text[max(0, len(text)-60):], - ) + t.Errorf("Expected text to end with truncation notice, got: %q", text[max(0, len(text)-60):]) } } } @@ -289,13 +263,11 @@ func TestWebTool_WebFetch_TruncationNotice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", tt.contentType) - w.WriteHeader(http.StatusOK) - w.Write([]byte(tt.body)) - }), - ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tt.contentType) + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.body)) + })) defer server.Close() tool, err := NewWebFetchTool(maxChars, tt.format, testFetchLimit) @@ -319,11 +291,7 @@ func TestWebTool_WebFetch_TruncationNotice(t *testing.T) { } if !strings.HasSuffix(text, truncationNotice) { - t.Errorf( - "expected text to end with %q, got suffix: %q", - truncationNotice, - text[max(0, len(text)-60):], - ) + t.Errorf("expected text to end with %q, got suffix: %q", truncationNotice, text[max(0, len(text)-60):]) } if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { @@ -392,11 +360,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { // Initialize the tool tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } // Prepare the arguments pointing to the URL of our local mock server @@ -416,8 +380,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { // Search for the exact error string we set earlier in the Execute method expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) - if !strings.Contains(result.ForLLM, expectedErrorMsg) && - !strings.Contains(result.ForUser, expectedErrorMsg) { + if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) } } @@ -570,11 +533,7 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() @@ -759,13 +718,7 @@ func TestWebTool_WebFetch_PrivateHostAllowedByCIDRWhitelist(t *testing.T) { defer server.Close() host, _ := serverHostAndPort(t, server.URL) - tool, err := NewWebFetchToolWithConfig( - 50000, - "", - format, - testFetchLimit, - []string{singleHostCIDR(t, host)}, - ) + tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{singleHostCIDR(t, host)}) if err != nil { t.Fatalf("Failed to create web fetch tool: %v", err) } @@ -800,10 +753,7 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) { }) if result.IsError { - t.Errorf( - "expected success when private host access is allowed in tests, got %q", - result.ForLLM, - ) + t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM) } } @@ -1023,11 +973,7 @@ func TestIsPrivateOrRestrictedIP_Table(t *testing.T) { func TestWebTool_WebFetch_MissingDomain(t *testing.T) { tool, err := NewWebFetchTool(50000, format, testFetchLimit) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } ctx := context.Background() @@ -1049,19 +995,9 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) { } func TestNewWebFetchToolWithProxy(t *testing.T) { - tool, err := NewWebFetchToolWithProxy( - 1024, - "http://127.0.0.1:7890", - format, - testFetchLimit, - nil, - ) + tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", format, testFetchLimit, nil) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } else if tool.maxChars != 1024 { t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) } @@ -1072,11 +1008,7 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", format, testFetchLimit, nil) if err != nil { - logger.ErrorCF( - "agent", - "Failed to create web fetch tool", - map[string]any{"error": err.Error()}, - ) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } if tool.maxChars != 50000 { @@ -1085,13 +1017,7 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { } func TestNewWebFetchToolWithConfig_InvalidPrivateHostWhitelist(t *testing.T) { - _, err := NewWebFetchToolWithConfig( - 1024, - "", - format, - testFetchLimit, - []string{"not-an-ip-or-cidr"}, - ) + _, err := NewWebFetchToolWithConfig(1024, "", format, testFetchLimit, []string{"not-an-ip-or-cidr"}) if err == nil { t.Fatal("expected invalid whitelist entry to fail") } @@ -1247,11 +1173,7 @@ func TestWebTool_TavilySearch_RangeMapping(t *testing.T) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "results": []map[string]any{ - { - "title": "Recent result", - "url": "https://example.com/recent", - "content": "snippet", - }, + {"title": "Recent result", "url": "https://example.com/recent", "content": "snippet"}, }, }) })) @@ -1381,10 +1303,7 @@ func TestWebFetchTool_CloudflareChallenge_RetryFailsToo(t *testing.T) { // Should not be an error — the retry response is used as-is (403 is a valid HTTP response) if result.IsError { - t.Fatalf( - "expected non-error result even when retry is also blocked, got: %s", - result.ForLLM, - ) + t.Fatalf("expected non-error result even when retry is also blocked, got: %s", result.ForLLM) } // Status in the JSON result should reflect the 403 if !strings.Contains(result.ForLLM, "403") { @@ -1549,10 +1468,7 @@ func TestWebTool_GLMSearch_Success(t *testing.T) { t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } if r.Header.Get("Authorization") != "Bearer test-glm-key" { - t.Errorf( - "Expected Authorization Bearer test-glm-key, got %s", - r.Header.Get("Authorization"), - ) + t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization")) } var payload map[string]any @@ -1618,21 +1534,14 @@ func TestWebTool_GLMSearch_RangeMapping(t *testing.T) { t.Fatalf("failed to decode payload: %v", err) } if payload["search_recency_filter"] != "oneMonth" { - t.Fatalf( - "expected search_recency_filter=oneMonth, got %v", - payload["search_recency_filter"], - ) + t.Fatalf("expected search_recency_filter=oneMonth, got %v", payload["search_recency_filter"]) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "search_result": []map[string]any{ - { - "title": "Recent GLM Result", - "content": "snippet", - "link": "https://example.com/glm-range", - }, + {"title": "Recent GLM Result", "content": "snippet", "link": "https://example.com/glm-range"}, }, }) })) @@ -1664,21 +1573,14 @@ func TestWebTool_BaiduSearch_RangeMapping(t *testing.T) { t.Fatalf("failed to decode payload: %v", err) } if payload["search_recency_filter"] != "week" { - t.Fatalf( - "expected search_recency_filter=week for day fallback, got %v", - payload["search_recency_filter"], - ) + t.Fatalf("expected search_recency_filter=week for day fallback, got %v", payload["search_recency_filter"]) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{ "references": []map[string]any{ - { - "title": "Recent Baidu Result", - "url": "https://example.com/baidu", - "content": "snippet", - }, + {"title": "Recent Baidu Result", "url": "https://example.com/baidu", "content": "snippet"}, }, }) })) From bca131909d52d814a6e149a60ad178c7af79debd Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 14:27:22 +0200 Subject: [PATCH 03/71] fix lint --- pkg/config/config.go | 119 +++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 67 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 8f793526b..aa5953840 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -106,18 +106,18 @@ const CurrentVersion = 1 // Config is the current config structure with version support type Config struct { - Version int `json:"version" yaml:"-"` // Config schema version for migration - Agents AgentsConfig `json:"agents" yaml:"-"` - Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` - Session SessionConfig `json:"session,omitempty" yaml:"-"` - Channels ChannelsConfig `json:"channels" yaml:"channels"` - ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway" yaml:"-"` - Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` - Tools ToolsConfig `json:"tools" yaml:",inline"` - Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` - Devices DevicesConfig `json:"devices" yaml:"-"` - Voice VoiceConfig `json:"voice" yaml:"-"` + Version int `json:"version" yaml:"-"` // Config schema version for migration + Agents AgentsConfig `json:"agents" yaml:"-"` + Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` + Session SessionConfig `json:"session,omitempty" yaml:"-"` + Channels ChannelsConfig `json:"channels" yaml:"channels"` + ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway" yaml:"-"` + Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` + Tools ToolsConfig `json:"tools" yaml:",inline"` + Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` + Devices DevicesConfig `json:"devices" yaml:"-"` + Voice VoiceConfig `json:"voice" yaml:"-"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"` @@ -819,8 +819,8 @@ type GLMSearchConfig struct { BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". - SearchEngine string `json:"search_engine" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` - MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` + SearchEngine string `json:"search_engine" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` } type BaiduSearchConfig struct { @@ -831,7 +831,7 @@ type BaiduSearchConfig struct { } type WebToolsConfig struct { - ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` + ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` @@ -844,13 +844,13 @@ type WebToolsConfig struct { // the client-side web_search tool is hidden to avoid duplicate search surfaces, // and the provider's built-in search is used instead. Falls back to client-side // search when the provider does not support native search. - PreferNative bool `yaml:"-" json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` + PreferNative bool `json:"prefer_native" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. - Proxy string `yaml:"-" json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 `yaml:"-" json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` - Format string `yaml:"-" json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` - PrivateHostWhitelist FlexibleStringSlice `yaml:"-" json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` + Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PROXY"` + FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` + Format string `json:"format,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FORMAT"` + PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { @@ -888,37 +888,37 @@ type ReadFileToolConfig struct { } type ToolsConfig struct { - AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` - AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` + AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` // FilterSensitiveData controls whether to filter sensitive values (API keys, // tokens, secrets) from tool results before sending to the LLM. // Default: true (enabled) - FilterSensitiveData bool `json:"filter_sensitive_data" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` + FilterSensitiveData bool `json:"filter_sensitive_data" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` // FilterMinLength is the minimum content length required for filtering. // Content shorter than this will be returned unchanged for performance. // Default: 8 - FilterMinLength int `json:"filter_min_length" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` - Web WebToolsConfig `json:"web" yaml:"web,omitempty"` - Cron CronToolsConfig `json:"cron" yaml:"-"` - Exec ExecConfig `json:"exec" yaml:"-"` - Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` - MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` - MCP MCPConfig `json:"mcp" yaml:"-"` - AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` - EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` - FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` - I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"` - InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` - 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_"` - SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` - Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` - SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` - SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` - Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` - WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` - WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` + FilterMinLength int `json:"filter_min_length" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` + Web WebToolsConfig `json:"web" yaml:"web,omitempty"` + Cron CronToolsConfig `json:"cron" yaml:"-"` + Exec ExecConfig `json:"exec" yaml:"-"` + Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` + MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` + MCP MCPConfig `json:"mcp" yaml:"-"` + AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` + EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` + FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` + I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"` + InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + 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_"` + SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` + Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` + SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` + SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` + Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` + WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled @@ -986,10 +986,10 @@ type MCPServerConfig struct { // MCPConfig defines configuration for all MCP servers type MCPConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` Discovery ToolDiscoveryConfig ` json:"discovery"` // Servers is a map of server name to server configuration - Servers map[string]MCPServerConfig ` json:"servers,omitempty"` + Servers map[string]MCPServerConfig `json:"servers,omitempty"` } func LoadConfig(path string) (*Config, error) { @@ -1000,10 +1000,7 @@ func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - logger.WarnF( - "config file not found, using default config", - map[string]any{"path": path}, - ) + logger.WarnF("config file not found, using default config", map[string]any{"path": path}) return DefaultConfig(), nil } logger.Errorf("failed to read config file: %v", err) @@ -1026,10 +1023,7 @@ func LoadConfig(path string) (*Config, error) { var cfg *Config switch versionInfo.Version { case 0: - logger.InfoF( - "config migrate start", - map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, - ) + logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) // Legacy config (no version field) v, e := loadConfigV0(data) if e != nil { @@ -1037,16 +1031,10 @@ func LoadConfig(path string) (*Config, error) { } cfg, e = v.Migrate() if e != nil { - logger.ErrorF( - "config migrate fail", - map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, - ) + logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) return nil, e } - logger.InfoF( - "config migrate success", - map[string]any{"from": versionInfo.Version, "to": CurrentVersion}, - ) + logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) err = makeBackup(path) if err != nil { return nil, err @@ -1054,10 +1042,7 @@ func LoadConfig(path string) (*Config, error) { // Load existing security config and merge with migrated one to prevent data loss secErr := loadSecurityConfig(cfg, securityPath(path)) if secErr != nil && !os.IsNotExist(secErr) { - logger.WarnF( - "failed to load existing security config during migration", - map[string]any{"error": secErr}, - ) + logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr}) return nil, fmt.Errorf("failed to load existing security config: %w", secErr) } defer func(cfg *Config) { From 6429f6af9a63e0ffc6c5cfc6e602e1abe9adf80c Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 22:43:20 +0200 Subject: [PATCH 04/71] refactor(agent): source discovery identity from AGENT.md frontmatter --- docs/configuration.md | 47 ++++++------ docs/it/configuration.md | 47 ++++++------ pkg/agent/definition.go | 2 +- pkg/agent/discovery.go | 140 ++++++++++++++++-------------------- pkg/agent/discovery_test.go | 64 +++++++++++++---- pkg/agent/instance.go | 33 +++++++-- pkg/agent/instance_test.go | 39 ++++++++++ pkg/agent/registry_test.go | 47 ++++++++++-- pkg/agent/tool_allowlist.go | 10 ++- pkg/config/config.go | 1 - pkg/config/config_test.go | 5 -- pkg/tools/registry.go | 4 +- pkg/tools/registry_test.go | 2 +- 13 files changed, 281 insertions(+), 160 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9c201c787..ab18bcaf5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -248,22 +248,20 @@ In other words: **channel + account form the candidate set; peer/guild/team then ### Agent Tool Allowlist -You can restrict an individual agent to a subset of runtime tools with `agents.list[].tools`. +Per-agent tool declarations live in `AGENT.md` frontmatter, not in `config.json`. -If `tools` is omitted, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed tools for that agent. +If `tools` is omitted from frontmatter, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed runtime tools for that agent. -```json -{ - "agents": { - "list": [ - { - "id": "research", - "name": "Research Agent", - "tools": ["read_file", "write_file", "web_search", "web_fetch", "message"] - } - ] - } -} +```md +--- +name: Research Agent +description: Specialist for web research and in-depth analysis. +tools: [read_file, write_file, web_search, web_fetch, message] +skills: [deep-research] +mcpServers: [web-index] +--- + +You are the research agent. ``` Notes: @@ -271,7 +269,7 @@ Notes: - This is an allowlist, not a preference hint. - Tool names are matched against the runtime tool name 1:1. - Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- The `available_tools` field in Agent Discovery reflects the filtered runtime result. +- `available_tools` in Agent Discovery reflects the filtered runtime result, while `tools` reflects the identity declared in `AGENT.md`. ### Agent Discovery (Automatic) @@ -284,9 +282,12 @@ Each entry includes: | Field | Meaning | |-------|---------| | `id` | Stable agent id | -| `name` | Human-friendly agent name | -| `description` | Short capability summary | -| `model` | Current model used by that agent | +| `name` | Agent identity name from `AGENT.md` frontmatter | +| `description` | Agent identity description from `AGENT.md` frontmatter | +| `tools` | Declared tool identity from `AGENT.md` frontmatter | +| `skills` | Declared skill identity from `AGENT.md` frontmatter | +| `mcpServers` | Declared MCP server identity from `AGENT.md` frontmatter | +| `model` | Declared model from `AGENT.md` frontmatter | | `available_tools` | Tool names currently visible to that agent | | `channels` | Channels that route to that agent | @@ -294,8 +295,8 @@ Important behavior: - The discovery section includes the current agent's own entry, so the model has self-awareness. - `available_tools` is the most important field for delegation. It reflects the tools the target agent can actually use, not just a natural-language description. -- `description` is sourced from `AGENT.md` frontmatter `description` when available, otherwise from the first meaningful paragraph of `AGENT.md`, and finally `SOUL.md`. -- `name` comes from `agents.list[].name` first, then `AGENT.md` frontmatter `name`, then falls back to the agent id. +- Identity fields (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) come from `AGENT.md` frontmatter. +- `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. - `channels` come from routing state: - the default agent exposes enabled channels - other agents expose channels that explicitly bind to them through `bindings` @@ -310,6 +311,9 @@ Example injected shape: "id": "main", "name": "Main Assistant", "description": "Generalist agent for day-to-day requests.", + "tools": ["read_file", "write_file", "exec", "spawn"], + "skills": ["coordination"], + "mcpServers": ["filesystem"], "model": "gpt-4o-mini", "available_tools": ["read_file", "write_file", "exec", "spawn"], "channels": ["telegram", "discord"] @@ -318,6 +322,9 @@ Example injected shape: "id": "research", "name": "Research Agent", "description": "Specialist for long-form investigation and web work.", + "tools": ["read_file", "web_search", "web_fetch", "message"], + "skills": ["deep-research"], + "mcpServers": ["web-index"], "model": "claude-sonnet-4.5", "available_tools": ["web_search", "web_fetch", "read_file"], "channels": ["telegram"] diff --git a/docs/it/configuration.md b/docs/it/configuration.md index 9b0d4a198..ef77f55ab 100644 --- a/docs/it/configuration.md +++ b/docs/it/configuration.md @@ -73,22 +73,20 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ### Allowlist dei Tool per Agent -Puoi limitare un singolo agent a un sottoinsieme di tool runtime con `agents.list[].tools`. +La dichiarazione dei tool per-agent vive nel frontmatter di `AGENT.md`, non in `config.json`. -Se `tools` è omesso, l'agent riceve il normale set globale dei tool abilitati. Se `tools` è presente, PicoClaw registra per quell'agent solo i tool elencati. +Se `tools` è omesso nel frontmatter, l'agent riceve il normale set globale dei tool abilitati. Se `tools` è presente, PicoClaw registra per quell'agent solo i tool runtime elencati. -```json -{ - "agents": { - "list": [ - { - "id": "research", - "name": "Research Agent", - "tools": ["read_file", "write_file", "web_search", "web_fetch", "message"] - } - ] - } -} +```md +--- +name: Research Agent +description: Specialista per ricerca web e analisi approfondita. +tools: [read_file, write_file, web_search, web_fetch, message] +skills: [deep-research] +mcpServers: [web-index] +--- + +Sei l'agent di ricerca. ``` Note: @@ -96,7 +94,7 @@ Note: - È una allowlist reale, non un suggerimento per l'LLM. - I nomi dei tool fanno match 1:1 con il nome runtime del tool. - Se ti serve controllo preciso, usa i nomi runtime effettivi come `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- Il campo `available_tools` nella Agent Discovery riflette il risultato filtrato reale. +- `available_tools` nella Agent Discovery riflette il risultato runtime filtrato, mentre `tools` riflette l'identità dichiarata in `AGENT.md`. ### Discovery Multi-Agent (Automatica) @@ -109,9 +107,12 @@ Ogni entry include: | Campo | Significato | |-------|-------------| | `id` | ID stabile dell'agent | -| `name` | Nome leggibile dell'agent | -| `description` | Riassunto breve delle capacità | -| `model` | Modello attualmente usato da quell'agent | +| `name` | Nome identitario da `AGENT.md` frontmatter | +| `description` | Descrizione identitaria da `AGENT.md` frontmatter | +| `tools` | Tool dichiarati nel frontmatter di `AGENT.md` | +| `skills` | Skill dichiarate nel frontmatter di `AGENT.md` | +| `mcpServers` | Server MCP dichiarati nel frontmatter di `AGENT.md` | +| `model` | Modello dichiarato nel frontmatter di `AGENT.md` | | `available_tools` | Tool attualmente visibili a quell'agent | | `channels` | Canali instradati verso quell'agent | @@ -119,8 +120,8 @@ Dettagli importanti: - La sezione include anche l'entry dell'agent corrente, quindi c'è self-awareness. - `available_tools` è il campo più importante per delegare bene: l'LLM vede i tool reali del peer, non deve indovinarli dalla sola descrizione. -- `description` viene presa da `AGENT.md` frontmatter `description` quando presente; altrimenti dal primo paragrafo utile di `AGENT.md`, e in fallback da `SOUL.md`. -- `name` arriva prima da `agents.list[].name`, poi da `AGENT.md` frontmatter `name`, e in fallback dall'ID dell'agent. +- I campi di identità (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) arrivano dal frontmatter di `AGENT.md`. +- `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. - `channels` derivano dal routing: - l'agent di default espone i canali abilitati - gli altri agent espongono i canali che hanno un binding esplicito verso di loro @@ -135,6 +136,9 @@ Forma dell'oggetto iniettato: "id": "main", "name": "Main Assistant", "description": "Agent generalista per richieste quotidiane.", + "tools": ["read_file", "write_file", "exec", "spawn"], + "skills": ["coordination"], + "mcpServers": ["filesystem"], "model": "gpt-4o-mini", "available_tools": ["read_file", "write_file", "exec", "spawn"], "channels": ["telegram", "discord"] @@ -143,6 +147,9 @@ Forma dell'oggetto iniettato: "id": "research", "name": "Research Agent", "description": "Specialista per investigazioni e lavoro web.", + "tools": ["read_file", "web_search", "web_fetch", "message"], + "skills": ["deep-research"], + "mcpServers": ["web-index"], "model": "claude-sonnet-4.5", "available_tools": ["web_search", "web_fetch", "read_file"], "channels": ["telegram"] diff --git a/pkg/agent/definition.go b/pkg/agent/definition.go index cf73d607c..90a69eaa4 100644 --- a/pkg/agent/definition.go +++ b/pkg/agent/definition.go @@ -35,7 +35,7 @@ type AgentFrontmatter struct { MaxTurns *int `json:"maxTurns,omitempty"` Skills []string `json:"skills,omitempty"` MCPServers []string `json:"mcpServers,omitempty"` - Fields map[string]any `json:"fields,omitempty"` + Fields map[string]any `json:"-"` } // AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file. diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index b630abd60..31b05c635 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "reflect" "sort" "strings" @@ -14,10 +15,8 @@ import ( // AgentDescriptor is the structured discovery payload injected into each // agent's system prompt so the LLM can make concrete delegation decisions. type AgentDescriptor struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Model string `json:"model"` + ID string `json:"id"` + AgentFrontmatter AvailableTools []string `json:"available_tools"` Channels []string `json:"channels"` } @@ -82,21 +81,12 @@ func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bo func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor { definition := loadAgentDefinition(agent.Workspace) - name := strings.TrimSpace(agent.Name) - if name == "" && definition.Agent != nil { - name = strings.TrimSpace(definition.Agent.Frontmatter.Name) - } - if name == "" { - name = agent.ID - } return AgentDescriptor{ - ID: agent.ID, - Name: name, - Description: agentDescriptionFromDefinition(definition), - Model: strings.TrimSpace(agent.Model), - AvailableTools: visibleToolNames(agent), - Channels: r.channelsForAgentLocked(agent.ID), + ID: agent.ID, + AgentFrontmatter: descriptorFrontmatter(agent.ID, definition), + AvailableTools: visibleToolNames(agent), + Channels: r.channelsForAgentLocked(agent.ID), } } @@ -120,21 +110,25 @@ func visibleToolNames(agent *AgentInstance) []string { return names } -func agentDescriptionFromDefinition(definition AgentContextDefinition) string { +func descriptorFrontmatter(agentID string, definition AgentContextDefinition) AgentFrontmatter { + frontmatter := AgentFrontmatter{} if definition.Agent != nil { - if desc := strings.TrimSpace(definition.Agent.Frontmatter.Description); desc != "" { - return desc - } - if desc := firstMeaningfulParagraph(definition.Agent.Body); desc != "" { - return desc - } + frontmatter = definition.Agent.Frontmatter + frontmatter.Tools = append([]string(nil), frontmatter.Tools...) + frontmatter.Skills = append([]string(nil), frontmatter.Skills...) + frontmatter.MCPServers = append([]string(nil), frontmatter.MCPServers...) } - if definition.Soul != nil { - if desc := firstMeaningfulParagraph(definition.Soul.Content); desc != "" { - return desc - } + + if strings.TrimSpace(frontmatter.Name) == "" { + frontmatter.Name = agentID } - return "" + if strings.TrimSpace(frontmatter.Description) == "" && + definition.Source == AgentDefinitionSourceAgents && + definition.Agent != nil { + frontmatter.Description = firstMeaningfulParagraph(definition.Agent.Body) + } + + return frontmatter } func firstMeaningfulParagraph(content string) string { @@ -171,9 +165,10 @@ func firstMeaningfulParagraph(content string) string { func (r *AgentRegistry) channelsForAgentLocked(agentID string) []string { channels := make(map[string]struct{}) + enabled := enabledChannelSet(r.cfg) if defaultID := r.defaultAgentIDLocked(); defaultID != "" && defaultID == agentID { - for _, channel := range enabledChannels(r.cfg) { + for channel := range enabled { channels[channel] = struct{}{} } } @@ -187,6 +182,9 @@ func (r *AgentRegistry) channelsForAgentLocked(agentID string) []string { if channel == "" { continue } + if _, ok := enabled[channel]; !ok { + continue + } channels[channel] = struct{}{} } } @@ -208,58 +206,42 @@ func enabledChannels(cfg *config.Config) []string { return []string{} } - enabled := make([]string, 0, 16) - if cfg.Channels.WhatsApp.Enabled { - enabled = append(enabled, "whatsapp") - } - if cfg.Channels.Telegram.Enabled { - enabled = append(enabled, "telegram") - } - if cfg.Channels.Feishu.Enabled { - enabled = append(enabled, "feishu") - } - if cfg.Channels.Discord.Enabled { - enabled = append(enabled, "discord") - } - if cfg.Channels.MaixCam.Enabled { - enabled = append(enabled, "maixcam") - } - if cfg.Channels.QQ.Enabled { - enabled = append(enabled, "qq") - } - if cfg.Channels.DingTalk.Enabled { - enabled = append(enabled, "dingtalk") - } - if cfg.Channels.Slack.Enabled { - enabled = append(enabled, "slack") - } - if cfg.Channels.Matrix.Enabled { - enabled = append(enabled, "matrix") - } - if cfg.Channels.LINE.Enabled { - enabled = append(enabled, "line") - } - if cfg.Channels.OneBot.Enabled { - enabled = append(enabled, "onebot") - } - if cfg.Channels.WeCom.Enabled { - enabled = append(enabled, "wecom") - } - if cfg.Channels.Weixin.Enabled { - enabled = append(enabled, "weixin") - } - if cfg.Channels.Pico.Enabled { - enabled = append(enabled, "pico") - } - if cfg.Channels.PicoClient.Enabled { - enabled = append(enabled, "pico_client") - } - if cfg.Channels.IRC.Enabled { - enabled = append(enabled, "irc") + value := reflect.ValueOf(cfg.Channels) + typ := value.Type() + enabled := make([]string, 0, typ.NumField()) + for i := 0; i < typ.NumField(); i++ { + fieldValue := value.Field(i) + enabledField := fieldValue.FieldByName("Enabled") + if !enabledField.IsValid() || enabledField.Kind() != reflect.Bool || !enabledField.Bool() { + continue + } + name := jsonFieldName(typ.Field(i).Tag.Get("json")) + if name == "" { + continue + } + enabled = append(enabled, name) } + sort.Strings(enabled) return enabled } +func enabledChannelSet(cfg *config.Config) map[string]struct{} { + channels := enabledChannels(cfg) + result := make(map[string]struct{}, len(channels)) + for _, channel := range channels { + result[channel] = struct{}{} + } + return result +} + +func jsonFieldName(tag string) string { + name := strings.TrimSpace(strings.Split(tag, ",")[0]) + if name == "" || name == "-" { + return "" + } + return name +} + func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string { agent, ok := r.agents[routing.NormalizeAgentID(agentID)] if !ok || agent == nil { @@ -331,7 +313,7 @@ func formatAgentDiscoverySection(currentAgentID string, agents []AgentDescriptor header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") } header.WriteString( - "Delegate based on available_tools first, then model, channels, and description. Use only agent IDs listed here.\n\n", + "Delegate based on available_tools first, then skills, mcpServers, model, channels, and description. Use only agent IDs listed here.\n\n", ) header.WriteString("```json\n") header.Write(encoded) diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go index a44f67dea..83a5472e3 100644 --- a/pkg/agent/discovery_test.go +++ b/pkg/agent/discovery_test.go @@ -13,6 +13,10 @@ func TestAgentRegistry_ListAgentsBuildsStructuredDescriptors(t *testing.T) { "AGENT.md": `--- name: Main Frontmatter Name description: Structured main agent +model: main-frontmatter-model +tools: [read_file, write_file] +skills: [coordination] +mcpServers: [filesystem] --- # Agent @@ -22,21 +26,24 @@ Handle general requests. defer cleanupWorkspace(t, mainWorkspace) supportWorkspace := setupWorkspace(t, map[string]string{ - "AGENT.md": `# Agent + "AGENT.md": `--- +name: Support Frontmatter Name +description: Support frontmatter description +model: support-frontmatter-model +tools: [read_file] +skills: [support-playbook] +mcpServers: [support-db] +--- +# Agent Handle support tickets carefully. `, - "SOUL.md": "# Soul\nStay calm and precise.", }) defer cleanupWorkspace(t, supportWorkspace) cfg := testCfg([]config.AgentConfig{ {ID: "main", Default: true, Name: "Configured Main", Workspace: mainWorkspace}, - { - ID: "support", - Workspace: supportWorkspace, - Model: &config.AgentModelConfig{Primary: "support-model"}, - }, + {ID: "support", Workspace: supportWorkspace}, }) cfg.Tools.ReadFile.Enabled = true cfg.Tools.WriteFile.Enabled = true @@ -61,14 +68,23 @@ Handle support tickets carefully. if descriptors[0].ID != "main" { t.Fatalf("expected current workspace agent first, got %q", descriptors[0].ID) } - if descriptors[0].Name != "Configured Main" { - t.Fatalf("expected config name to win, got %q", descriptors[0].Name) + if descriptors[0].Name != "Main Frontmatter Name" { + t.Fatalf("expected frontmatter name to drive discovery, got %q", descriptors[0].Name) } if descriptors[0].Description != "Structured main agent" { t.Fatalf("expected frontmatter description, got %q", descriptors[0].Description) } - if descriptors[0].Model != "gpt-4" { - t.Fatalf("expected inherited model, got %q", descriptors[0].Model) + if descriptors[0].Model != "main-frontmatter-model" { + t.Fatalf("expected frontmatter model, got %q", descriptors[0].Model) + } + if !slices.Equal(descriptors[0].Tools, []string{"read_file", "write_file"}) { + t.Fatalf("expected declared frontmatter tools, got %v", descriptors[0].Tools) + } + if !slices.Equal(descriptors[0].Skills, []string{"coordination"}) { + t.Fatalf("expected frontmatter skills, got %v", descriptors[0].Skills) + } + if !slices.Equal(descriptors[0].MCPServers, []string{"filesystem"}) { + t.Fatalf("expected frontmatter mcpServers, got %v", descriptors[0].MCPServers) } if !slices.Contains(descriptors[0].AvailableTools, "read_file") || !slices.Contains(descriptors[0].AvailableTools, "write_file") { @@ -85,11 +101,20 @@ Handle support tickets carefully. if !ok || support == nil { t.Fatal("expected support descriptor lookup to succeed") } - if support.Description != "Handle support tickets carefully." { - t.Fatalf("expected AGENT body fallback description, got %q", support.Description) + if support.Name != "Support Frontmatter Name" { + t.Fatalf("expected support frontmatter name, got %q", support.Name) } - if support.Model != "support-model" { - t.Fatalf("expected explicit support model, got %q", support.Model) + if support.Description != "Support frontmatter description" { + t.Fatalf("expected support frontmatter description, got %q", support.Description) + } + if support.Model != "support-frontmatter-model" { + t.Fatalf("expected support frontmatter model, got %q", support.Model) + } + if !slices.Equal(support.Skills, []string{"support-playbook"}) { + t.Fatalf("expected support skills, got %v", support.Skills) + } + if !slices.Equal(support.MCPServers, []string{"support-db"}) { + t.Fatalf("expected support mcpServers, got %v", support.MCPServers) } if !slices.Equal(support.Channels, []string{"telegram"}) { t.Fatalf("expected support channel binding, got %v", support.Channels) @@ -100,6 +125,7 @@ func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- description: Main agent +skills: [coordination] --- # Agent @@ -111,6 +137,8 @@ Generalist. researchWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- description: Research specialist +skills: [deep-research] +mcpServers: [web-index] --- # Agent @@ -162,6 +190,12 @@ Investigate deeply. !strings.Contains(systemPrompt, `"write_file"`) { t.Fatalf("expected visible tool list in discovery section, got %q", systemPrompt) } + if !strings.Contains(systemPrompt, `"skills": [`) || !strings.Contains(systemPrompt, `"deep-research"`) { + t.Fatalf("expected frontmatter skills in discovery section, got %q", systemPrompt) + } + if !strings.Contains(systemPrompt, `"mcpServers": [`) || !strings.Contains(systemPrompt, `"web-index"`) { + t.Fatalf("expected frontmatter mcpServers in discovery section, got %q", systemPrompt) + } } func TestContextBuilder_BuildMessagesOmitsAgentDiscoverySectionForSingleton(t *testing.T) { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 4b3b4b3ee..89bf0416a 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -63,7 +63,9 @@ func NewAgentInstance( workspace := resolveAgentWorkspace(agentCfg, defaults) os.MkdirAll(workspace, 0o755) - model := resolveAgentModel(agentCfg, defaults) + definition := loadAgentDefinition(workspace) + + model := resolveAgentModel(agentCfg, defaults, definition) fallbacks := resolveAgentFallbacks(agentCfg, defaults) restrict := defaults.RestrictToWorkspace @@ -72,7 +74,7 @@ func NewAgentInstance( // Compile path whitelist patterns from config. allowReadPaths := buildAllowReadPatterns(cfg) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) - agentToolAllowlist := resolveAgentToolAllowlist(agentCfg) + agentToolAllowlist := resolveAgentToolAllowlist(definition) toolsRegistry := tools.NewToolRegistry() toolsRegistry.SetAllowlist(agentToolAllowlist) @@ -125,8 +127,11 @@ func NewAgentInstance( if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) agentName = agentCfg.Name + if definition.Agent != nil && strings.TrimSpace(definition.Agent.Frontmatter.Name) != "" { + agentName = strings.TrimSpace(definition.Agent.Frontmatter.Name) + } subagents = agentCfg.Subagents - skillsFilter = agentCfg.Skills + skillsFilter = resolveAgentSkillsFilter(agentCfg, definition) } maxIter := defaults.MaxToolIterations @@ -255,7 +260,14 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD } // resolveAgentModel resolves the primary model for an agent. -func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { +func resolveAgentModel( + agentCfg *config.AgentConfig, + defaults *config.AgentDefaults, + definition AgentContextDefinition, +) string { + if definition.Agent != nil && strings.TrimSpace(definition.Agent.Frontmatter.Model) != "" { + return strings.TrimSpace(definition.Agent.Frontmatter.Model) + } if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { return strings.TrimSpace(agentCfg.Model.Primary) } @@ -270,6 +282,19 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD return defaults.ModelFallbacks } +func resolveAgentSkillsFilter( + agentCfg *config.AgentConfig, + definition AgentContextDefinition, +) []string { + if definition.Agent != nil && definition.Agent.Frontmatter.Skills != nil { + return append([]string(nil), definition.Agent.Frontmatter.Skills...) + } + if agentCfg == nil || agentCfg.Skills == nil { + return nil + } + return append([]string(nil), agentCfg.Skills...) +} + func compilePatterns(patterns []string) []*regexp.Regexp { compiled := make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index e296a18cb..aedda1c32 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -281,3 +281,42 @@ func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { t.Fatal("read_file tool should still be registered") } } + +func TestNewAgentInstance_UsesFrontmatterModelAndSkills(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +model: frontmatter-model +skills: [frontmatter-skill] +--- +# Agent + +Use frontmatter identity. +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "default-model", + }, + }, + } + + agent := NewAgentInstance(&config.AgentConfig{ + ID: "research", + Workspace: workspace, + Model: &config.AgentModelConfig{ + Primary: "config-model", + }, + Skills: []string{"config-skill"}, + }, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + if agent.Model != "frontmatter-model" { + t.Fatalf("agent.Model = %q, want frontmatter-model", agent.Model) + } + if len(agent.SkillsFilter) != 1 || agent.SkillsFilter[0] != "frontmatter-skill" { + t.Fatalf("agent.SkillsFilter = %v, want [frontmatter-skill]", agent.SkillsFilter) + } +} diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index 2b577ab93..62b2ea6eb 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -211,13 +211,31 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { } func TestNewAgentLoop_AgentToolAllowlistFiltersRuntimeTools(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nMain agent.\n", + }) + defer cleanupWorkspace(t, mainWorkspace) + + researchWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +tools: [read_file, write_file, web_search, web_fetch, message] +skills: [deep-research] +--- +# Agent + +Research agent. +`, + }) + defer cleanupWorkspace(t, researchWorkspace) + cfg := testCfg([]config.AgentConfig{ - {ID: "main", Default: true}, + {ID: "main", Default: true, Workspace: mainWorkspace}, { - ID: "research", - Tools: []string{"read_file", "write_file", "web_search", "web_fetch", "message"}, + ID: "research", + Workspace: researchWorkspace, }, }) + cfg.Agents.Defaults.Workspace = mainWorkspace cfg.Tools.ReadFile.Enabled = true cfg.Tools.WriteFile.Enabled = true cfg.Tools.ListDir.Enabled = true @@ -251,13 +269,30 @@ func TestNewAgentLoop_AgentToolAllowlistFiltersRuntimeTools(t *testing.T) { } func TestNewAgentLoop_AgentToolAllowlistRequiresExactRuntimeToolNames(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nMain agent.\n", + }) + defer cleanupWorkspace(t, mainWorkspace) + + researchWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +tools: [web] +--- +# Agent + +Research agent. +`, + }) + defer cleanupWorkspace(t, researchWorkspace) + cfg := testCfg([]config.AgentConfig{ - {ID: "main", Default: true}, + {ID: "main", Default: true, Workspace: mainWorkspace}, { - ID: "research", - Tools: []string{"web"}, + ID: "research", + Workspace: researchWorkspace, }, }) + cfg.Agents.Defaults.Workspace = mainWorkspace cfg.Tools.Web.Enabled = true cfg.Tools.Web.DuckDuckGo.Enabled = true diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index 41b1fb98b..899c84b89 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -3,17 +3,15 @@ package agent import ( "sort" "strings" - - "github.com/sipeed/picoclaw/pkg/config" ) -func resolveAgentToolAllowlist(agentCfg *config.AgentConfig) []string { - if agentCfg == nil || agentCfg.Tools == nil { +func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { + if definition.Agent == nil || definition.Agent.Frontmatter.Tools == nil { return nil } - allowlist := make(map[string]struct{}, len(agentCfg.Tools)) - for _, raw := range agentCfg.Tools { + allowlist := make(map[string]struct{}, len(definition.Agent.Frontmatter.Tools)) + for _, raw := range definition.Agent.Frontmatter.Tools { trimmed := strings.ToLower(strings.TrimSpace(raw)) if trimmed == "" { continue diff --git a/pkg/config/config.go b/pkg/config/config.go index aa5953840..533f45a44 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -248,7 +248,6 @@ type AgentConfig struct { Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` - Tools []string `json:"tools,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a22bcd7cb..afb4ce425 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -120,7 +120,6 @@ func TestAgentConfig_FullParse(t *testing.T) { "primary": "claude-opus", "fallbacks": ["haiku"] }, - "tools": ["read_file", "web_search"], "subagents": { "allow_agents": ["sales"] } @@ -172,10 +171,6 @@ func TestAgentConfig_FullParse(t *testing.T) { if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" { t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) } - if len(support.Tools) != 2 || support.Tools[0] != "read_file" || - support.Tools[1] != "web_search" { - t.Errorf("support.Tools = %v", support.Tools) - } if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 { t.Errorf("support.Subagents = %+v", support.Subagents) } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index e16be0ccb..1e6263dc8 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -51,7 +51,7 @@ func (r *ToolRegistry) SetAllowlist(names []string) { allowlist := make(map[string]struct{}, len(names)) for _, name := range names { - trimmed := strings.TrimSpace(name) + trimmed := strings.ToLower(strings.TrimSpace(name)) if trimmed == "" { continue } @@ -172,7 +172,7 @@ func (r *ToolRegistry) toolAllowedLocked(name string) bool { if r.allowlist == nil { return true } - _, ok := r.allowlist[name] + _, ok := r.allowlist[strings.ToLower(strings.TrimSpace(name))] return ok } diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 17b3cd127..2633411ff 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -101,7 +101,7 @@ func TestToolRegistry_RegisterAndGet(t *testing.T) { func TestToolRegistry_AllowlistFiltersRegistrations(t *testing.T) { r := NewToolRegistry() - r.SetAllowlist([]string{"allowed_tool"}) + r.SetAllowlist([]string{"Allowed_Tool"}) r.Register(newMockTool("allowed_tool", "allowed")) r.Register(newMockTool("blocked_tool", "blocked")) From 0ef25f779e1e61cb1211b690d968771b9c41a089 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 22:57:57 +0200 Subject: [PATCH 05/71] refactor(agent): move delegation details out of discovery prompt --- docs/configuration.md | 34 ++------ docs/it/configuration.md | 34 ++------ pkg/agent/context.go | 8 +- pkg/agent/discovery.go | 163 ++++++------------------------------ pkg/agent/discovery_test.go | 81 ++---------------- pkg/agent/registry.go | 6 +- 6 files changed, 48 insertions(+), 278 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ab18bcaf5..e0ad00367 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -269,7 +269,7 @@ Notes: - This is an allowlist, not a preference hint. - Tool names are matched against the runtime tool name 1:1. - Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- `available_tools` in Agent Discovery reflects the filtered runtime result, while `tools` reflects the identity declared in `AGENT.md`. +- Tool declarations in `AGENT.md` are used by runtime/tooling, but they are not injected into the discovery prompt. ### Agent Discovery (Automatic) @@ -284,56 +284,34 @@ Each entry includes: | `id` | Stable agent id | | `name` | Agent identity name from `AGENT.md` frontmatter | | `description` | Agent identity description from `AGENT.md` frontmatter | -| `tools` | Declared tool identity from `AGENT.md` frontmatter | -| `skills` | Declared skill identity from `AGENT.md` frontmatter | -| `mcpServers` | Declared MCP server identity from `AGENT.md` frontmatter | -| `model` | Declared model from `AGENT.md` frontmatter | -| `available_tools` | Tool names currently visible to that agent | -| `channels` | Channels that route to that agent | Important behavior: - The discovery section includes the current agent's own entry, so the model has self-awareness. -- `available_tools` is the most important field for delegation. It reflects the tools the target agent can actually use, not just a natural-language description. -- Identity fields (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) come from `AGENT.md` frontmatter. +- Discovery is intentionally lightweight. It gives the model only the identity it needs to choose a peer: `id`, `name`, and `description`. - `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. -- `channels` come from routing state: - - the default agent exposes enabled channels - - other agents expose channels that explicitly bind to them through `bindings` +- `AGENT.md` remains the identity layer. Runtime/tool code can still use its `tools`, `skills`, `mcpServers`, and `model` fields when delegation happens. Example injected shape: ```json { - "current_agent_id": "main", "agents": [ { "id": "main", "name": "Main Assistant", - "description": "Generalist agent for day-to-day requests.", - "tools": ["read_file", "write_file", "exec", "spawn"], - "skills": ["coordination"], - "mcpServers": ["filesystem"], - "model": "gpt-4o-mini", - "available_tools": ["read_file", "write_file", "exec", "spawn"], - "channels": ["telegram", "discord"] + "description": "Generalist agent for day-to-day requests." }, { "id": "research", "name": "Research Agent", - "description": "Specialist for long-form investigation and web work.", - "tools": ["read_file", "web_search", "web_fetch", "message"], - "skills": ["deep-research"], - "mcpServers": ["web-index"], - "model": "claude-sonnet-4.5", - "available_tools": ["web_search", "web_fetch", "read_file"], - "channels": ["telegram"] + "description": "Specialist for long-form investigation and web work." } ] } ``` -In practice, this means a generalist agent can see that a peer has `["web_search", "web_fetch"]` while it only has local file tools, and can decide to delegate to that peer instead of guessing. +In practice, this means a generalist agent can choose a peer based on its role description, then call `spawn` with the peer's `agent_id`. The runtime resolves the rest. ### 🔒 Security Sandbox diff --git a/docs/it/configuration.md b/docs/it/configuration.md index ef77f55ab..4b0153e2c 100644 --- a/docs/it/configuration.md +++ b/docs/it/configuration.md @@ -94,7 +94,7 @@ Note: - È una allowlist reale, non un suggerimento per l'LLM. - I nomi dei tool fanno match 1:1 con il nome runtime del tool. - Se ti serve controllo preciso, usa i nomi runtime effettivi come `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- `available_tools` nella Agent Discovery riflette il risultato runtime filtrato, mentre `tools` riflette l'identità dichiarata in `AGENT.md`. +- Le dichiarazioni dei tool in `AGENT.md` sono usate dal runtime e dai tool, ma non vengono iniettate nel prompt di discovery. ### Discovery Multi-Agent (Automatica) @@ -109,56 +109,34 @@ Ogni entry include: | `id` | ID stabile dell'agent | | `name` | Nome identitario da `AGENT.md` frontmatter | | `description` | Descrizione identitaria da `AGENT.md` frontmatter | -| `tools` | Tool dichiarati nel frontmatter di `AGENT.md` | -| `skills` | Skill dichiarate nel frontmatter di `AGENT.md` | -| `mcpServers` | Server MCP dichiarati nel frontmatter di `AGENT.md` | -| `model` | Modello dichiarato nel frontmatter di `AGENT.md` | -| `available_tools` | Tool attualmente visibili a quell'agent | -| `channels` | Canali instradati verso quell'agent | Dettagli importanti: - La sezione include anche l'entry dell'agent corrente, quindi c'è self-awareness. -- `available_tools` è il campo più importante per delegare bene: l'LLM vede i tool reali del peer, non deve indovinarli dalla sola descrizione. -- I campi di identità (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) arrivano dal frontmatter di `AGENT.md`. +- La discovery è volutamente leggera. Fornisce al modello solo l'identità necessaria per scegliere un peer: `id`, `name`, `description`. - `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. -- `channels` derivano dal routing: - - l'agent di default espone i canali abilitati - - gli altri agent espongono i canali che hanno un binding esplicito verso di loro +- `AGENT.md` resta il layer di identità. Il codice runtime e i tool possono comunque usare `tools`, `skills`, `mcpServers` e `model` quando avviene la delega. Forma dell'oggetto iniettato: ```json { - "current_agent_id": "main", "agents": [ { "id": "main", "name": "Main Assistant", - "description": "Agent generalista per richieste quotidiane.", - "tools": ["read_file", "write_file", "exec", "spawn"], - "skills": ["coordination"], - "mcpServers": ["filesystem"], - "model": "gpt-4o-mini", - "available_tools": ["read_file", "write_file", "exec", "spawn"], - "channels": ["telegram", "discord"] + "description": "Agent generalista per richieste quotidiane." }, { "id": "research", "name": "Research Agent", - "description": "Specialista per investigazioni e lavoro web.", - "tools": ["read_file", "web_search", "web_fetch", "message"], - "skills": ["deep-research"], - "mcpServers": ["web-index"], - "model": "claude-sonnet-4.5", - "available_tools": ["web_search", "web_fetch", "read_file"], - "channels": ["telegram"] + "description": "Specialista per investigazioni e lavoro web." } ] } ``` -In pratica, un agent generalista può vedere che un peer ha `["web_search", "web_fetch"]` mentre lui ha solo tool locali, e scegliere di delegare a quel peer in modo esplicito invece di andare a tentativi. +In pratica, un agent generalista sceglie un peer in base alla descrizione del suo ruolo, poi chiama `spawn` con l'`agent_id` del peer. Il runtime risolve il resto. ### 🔒 Sandbox di Sicurezza diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 31b60e45a..1ff25f296 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -22,7 +22,6 @@ import ( type ContextBuilder struct { workspace string - agentID string skillsLoader *skills.SkillsLoader memory *MemoryStore toolDiscoveryBM25 bool @@ -60,11 +59,6 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { return cb } -func (cb *ContextBuilder) WithAgentIdentity(agentID string) *ContextBuilder { - cb.agentID = strings.TrimSpace(agentID) - return cb -} - func (cb *ContextBuilder) WithAgentDiscovery( discover func(workspace string) []AgentDescriptor, ) *ContextBuilder { @@ -200,7 +194,7 @@ func (cb *ContextBuilder) buildAgentDiscoveryContext() string { if cb.agentDiscovery == nil { return "" } - return formatAgentDiscoverySection(cb.agentID, cb.agentDiscovery(cb.workspace)) + return formatAgentDiscoverySection(cb.agentDiscovery(cb.workspace)) } // BuildSystemPromptWithCache returns the cached system prompt if available diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index 31b05c635..6cd49f2a6 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -2,23 +2,19 @@ package agent import ( "encoding/json" - "fmt" "path/filepath" - "reflect" "sort" "strings" - "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/routing" ) // AgentDescriptor is the structured discovery payload injected into each -// agent's system prompt so the LLM can make concrete delegation decisions. +// agent's system prompt so the LLM can choose a peer by identity. type AgentDescriptor struct { - ID string `json:"id"` - AgentFrontmatter - AvailableTools []string `json:"available_tools"` - Channels []string `json:"channels"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` } // ListAgents returns structured descriptors for every agent in the current @@ -81,54 +77,34 @@ func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bo func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor { definition := loadAgentDefinition(agent.Workspace) + name, description := descriptorIdentity(agent.ID, definition) return AgentDescriptor{ - ID: agent.ID, - AgentFrontmatter: descriptorFrontmatter(agent.ID, definition), - AvailableTools: visibleToolNames(agent), - Channels: r.channelsForAgentLocked(agent.ID), + ID: agent.ID, + Name: name, + Description: description, } } -func visibleToolNames(agent *AgentInstance) []string { - if agent == nil || agent.Tools == nil { - return []string{} - } - - defs := agent.Tools.ToProviderDefs() - names := make([]string, 0, len(defs)) - for _, def := range defs { - name := strings.TrimSpace(def.Function.Name) - if name == "" { - continue - } - names = append(names, name) - } - if names == nil { - return []string{} - } - return names -} - -func descriptorFrontmatter(agentID string, definition AgentContextDefinition) AgentFrontmatter { - frontmatter := AgentFrontmatter{} +func descriptorIdentity(agentID string, definition AgentContextDefinition) (string, string) { + name := agentID + description := "" if definition.Agent != nil { - frontmatter = definition.Agent.Frontmatter - frontmatter.Tools = append([]string(nil), frontmatter.Tools...) - frontmatter.Skills = append([]string(nil), frontmatter.Skills...) - frontmatter.MCPServers = append([]string(nil), frontmatter.MCPServers...) + if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Name); trimmed != "" { + name = trimmed + } + if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Description); trimmed != "" { + description = trimmed + } } - if strings.TrimSpace(frontmatter.Name) == "" { - frontmatter.Name = agentID - } - if strings.TrimSpace(frontmatter.Description) == "" && + if description == "" && definition.Source == AgentDefinitionSourceAgents && definition.Agent != nil { - frontmatter.Description = firstMeaningfulParagraph(definition.Agent.Body) + description = firstMeaningfulParagraph(definition.Agent.Body) } - return frontmatter + return name, description } func firstMeaningfulParagraph(content string) string { @@ -163,85 +139,6 @@ func firstMeaningfulParagraph(content string) string { return "" } -func (r *AgentRegistry) channelsForAgentLocked(agentID string) []string { - channels := make(map[string]struct{}) - enabled := enabledChannelSet(r.cfg) - - if defaultID := r.defaultAgentIDLocked(); defaultID != "" && defaultID == agentID { - for channel := range enabled { - channels[channel] = struct{}{} - } - } - - if r.cfg != nil { - for _, binding := range r.cfg.Bindings { - if routing.NormalizeAgentID(binding.AgentID) != agentID { - continue - } - channel := strings.ToLower(strings.TrimSpace(binding.Match.Channel)) - if channel == "" { - continue - } - if _, ok := enabled[channel]; !ok { - continue - } - channels[channel] = struct{}{} - } - } - - if len(channels) == 0 { - return []string{} - } - - result := make([]string, 0, len(channels)) - for channel := range channels { - result = append(result, channel) - } - sort.Strings(result) - return result -} - -func enabledChannels(cfg *config.Config) []string { - if cfg == nil { - return []string{} - } - - value := reflect.ValueOf(cfg.Channels) - typ := value.Type() - enabled := make([]string, 0, typ.NumField()) - for i := 0; i < typ.NumField(); i++ { - fieldValue := value.Field(i) - enabledField := fieldValue.FieldByName("Enabled") - if !enabledField.IsValid() || enabledField.Kind() != reflect.Bool || !enabledField.Bool() { - continue - } - name := jsonFieldName(typ.Field(i).Tag.Get("json")) - if name == "" { - continue - } - enabled = append(enabled, name) - } - sort.Strings(enabled) - return enabled -} - -func enabledChannelSet(cfg *config.Config) map[string]struct{} { - channels := enabledChannels(cfg) - result := make(map[string]struct{}, len(channels)) - for _, channel := range channels { - result[channel] = struct{}{} - } - return result -} - -func jsonFieldName(tag string) string { - name := strings.TrimSpace(strings.Split(tag, ",")[0]) - if name == "" || name == "-" { - return "" - } - return name -} - func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string { agent, ok := r.agents[routing.NormalizeAgentID(agentID)] if !ok || agent == nil { @@ -283,17 +180,15 @@ func cleanWorkspacePath(path string) string { return filepath.Clean(path) } -func formatAgentDiscoverySection(currentAgentID string, agents []AgentDescriptor) string { +func formatAgentDiscoverySection(agents []AgentDescriptor) string { if len(agents) <= 1 { return "" } payload := struct { - CurrentAgentID string `json:"current_agent_id"` - Agents []AgentDescriptor `json:"agents"` + Agents []AgentDescriptor `json:"agents"` }{ - CurrentAgentID: strings.TrimSpace(currentAgentID), - Agents: agents, + Agents: agents, } encoded, err := json.MarshalIndent(payload, "", " ") @@ -303,17 +198,9 @@ func formatAgentDiscoverySection(currentAgentID string, agents []AgentDescriptor var header strings.Builder header.WriteString("# Agent Discovery\n\n") - if payload.CurrentAgentID != "" { - fmt.Fprintf( - &header, - "You are agent %q. This registry is authoritative for the current PicoClaw instance and includes your own entry.\n", - payload.CurrentAgentID, - ) - } else { - header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") - } + header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") header.WriteString( - "Delegate based on available_tools first, then skills, mcpServers, model, channels, and description. Use only agent IDs listed here.\n\n", + "Choose a peer based on its description. Use only agent IDs listed here when calling spawn.\n\n", ) header.WriteString("```json\n") header.Write(encoded) diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go index 83a5472e3..4dbaea900 100644 --- a/pkg/agent/discovery_test.go +++ b/pkg/agent/discovery_test.go @@ -1,7 +1,6 @@ package agent import ( - "slices" "strings" "testing" @@ -13,10 +12,6 @@ func TestAgentRegistry_ListAgentsBuildsStructuredDescriptors(t *testing.T) { "AGENT.md": `--- name: Main Frontmatter Name description: Structured main agent -model: main-frontmatter-model -tools: [read_file, write_file] -skills: [coordination] -mcpServers: [filesystem] --- # Agent @@ -29,10 +24,6 @@ Handle general requests. "AGENT.md": `--- name: Support Frontmatter Name description: Support frontmatter description -model: support-frontmatter-model -tools: [read_file] -skills: [support-playbook] -mcpServers: [support-db] --- # Agent @@ -45,18 +36,6 @@ Handle support tickets carefully. {ID: "main", Default: true, Name: "Configured Main", Workspace: mainWorkspace}, {ID: "support", Workspace: supportWorkspace}, }) - cfg.Tools.ReadFile.Enabled = true - cfg.Tools.WriteFile.Enabled = true - cfg.Channels.Telegram.Enabled = true - cfg.Bindings = []config.AgentBinding{ - { - AgentID: "support", - Match: config.BindingMatch{ - Channel: "telegram", - AccountID: "*", - }, - }, - } registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) @@ -74,28 +53,6 @@ Handle support tickets carefully. if descriptors[0].Description != "Structured main agent" { t.Fatalf("expected frontmatter description, got %q", descriptors[0].Description) } - if descriptors[0].Model != "main-frontmatter-model" { - t.Fatalf("expected frontmatter model, got %q", descriptors[0].Model) - } - if !slices.Equal(descriptors[0].Tools, []string{"read_file", "write_file"}) { - t.Fatalf("expected declared frontmatter tools, got %v", descriptors[0].Tools) - } - if !slices.Equal(descriptors[0].Skills, []string{"coordination"}) { - t.Fatalf("expected frontmatter skills, got %v", descriptors[0].Skills) - } - if !slices.Equal(descriptors[0].MCPServers, []string{"filesystem"}) { - t.Fatalf("expected frontmatter mcpServers, got %v", descriptors[0].MCPServers) - } - if !slices.Contains(descriptors[0].AvailableTools, "read_file") || - !slices.Contains(descriptors[0].AvailableTools, "write_file") { - t.Fatalf("expected visible file tools in descriptor, got %v", descriptors[0].AvailableTools) - } - if !slices.Equal(descriptors[0].Channels, []string{"telegram"}) { - t.Fatalf( - "expected default agent to cover enabled telegram channel, got %v", - descriptors[0].Channels, - ) - } support, ok := registry.GetAgentDescriptor("support") if !ok || support == nil { @@ -107,25 +64,12 @@ Handle support tickets carefully. if support.Description != "Support frontmatter description" { t.Fatalf("expected support frontmatter description, got %q", support.Description) } - if support.Model != "support-frontmatter-model" { - t.Fatalf("expected support frontmatter model, got %q", support.Model) - } - if !slices.Equal(support.Skills, []string{"support-playbook"}) { - t.Fatalf("expected support skills, got %v", support.Skills) - } - if !slices.Equal(support.MCPServers, []string{"support-db"}) { - t.Fatalf("expected support mcpServers, got %v", support.MCPServers) - } - if !slices.Equal(support.Channels, []string{"telegram"}) { - t.Fatalf("expected support channel binding, got %v", support.Channels) - } } func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) { mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- description: Main agent -skills: [coordination] --- # Agent @@ -136,9 +80,8 @@ Generalist. researchWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- +name: Research Agent description: Research specialist -skills: [deep-research] -mcpServers: [web-index] --- # Agent @@ -178,23 +121,18 @@ Investigate deeply. if !strings.Contains(systemPrompt, "# Agent Discovery") { t.Fatalf("expected discovery section in system prompt, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"current_agent_id": "main"`) { - t.Fatalf("expected current agent id in discovery section, got %q", systemPrompt) - } if !strings.Contains(systemPrompt, `"id": "main"`) || !strings.Contains(systemPrompt, `"id": "research"`) { t.Fatalf("expected self and peer descriptors in discovery section, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"available_tools": [`) || - !strings.Contains(systemPrompt, `"read_file"`) || - !strings.Contains(systemPrompt, `"write_file"`) { - t.Fatalf("expected visible tool list in discovery section, got %q", systemPrompt) + if !strings.Contains(systemPrompt, `"name": "main"`) || + !strings.Contains(systemPrompt, `"description": "Research specialist"`) { + t.Fatalf("expected minimal identity fields in discovery section, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"skills": [`) || !strings.Contains(systemPrompt, `"deep-research"`) { - t.Fatalf("expected frontmatter skills in discovery section, got %q", systemPrompt) - } - if !strings.Contains(systemPrompt, `"mcpServers": [`) || !strings.Contains(systemPrompt, `"web-index"`) { - t.Fatalf("expected frontmatter mcpServers in discovery section, got %q", systemPrompt) + for _, forbidden := range []string{`"current_agent_id"`, `"available_tools"`, `"model"`, `"channels"`, `"skills"`, `"mcpServers"`, `"tools"`} { + if strings.Contains(systemPrompt, forbidden) { + t.Fatalf("did not expect %s in discovery section, got %q", forbidden, systemPrompt) + } } } @@ -239,7 +177,4 @@ Generalist. if strings.Contains(systemPrompt, "# Agent Discovery") { t.Fatalf("did not expect discovery section for singleton registry, got %q", systemPrompt) } - if strings.Contains(systemPrompt, `"current_agent_id": "main"`) { - t.Fatalf("did not expect discovery payload for singleton registry, got %q", systemPrompt) - } } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 46f54f5c8..ef5645e51 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -54,11 +54,9 @@ func NewAgentRegistry( } } - for id, instance := range registry.agents { + for _, instance := range registry.agents { if instance.ContextBuilder != nil { - instance.ContextBuilder. - WithAgentIdentity(id). - WithAgentDiscovery(registry.ListAgents) + instance.ContextBuilder.WithAgentDiscovery(registry.ListAgents) } } From 847218ef29a6bae4d44bceb6a1d95044ab2f9c57 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 23:22:47 +0200 Subject: [PATCH 06/71] refactor(agent): added mcp allowlist --- pkg/agent/instance.go | 11 +++++++++ pkg/agent/instance_test.go | 10 ++++++++ pkg/agent/loop_mcp.go | 9 +++++++ pkg/agent/loop_mcp_test.go | 49 +++++++++++++++++++++++++++++++++++++ pkg/agent/tool_allowlist.go | 17 +++++++++++++ 5 files changed, 96 insertions(+) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 89bf0416a..f95a165af 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -39,6 +39,7 @@ type AgentInstance struct { Tools *tools.ToolRegistry Subagents *config.SubagentsConfig SkillsFilter []string + MCPServerAllowlist map[string]struct{} Candidates []providers.FallbackCandidate // Router is non-nil when model routing is configured and the light model @@ -75,6 +76,7 @@ func NewAgentInstance( allowReadPaths := buildAllowReadPatterns(cfg) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) agentToolAllowlist := resolveAgentToolAllowlist(definition) + agentMCPServerAllowlist := resolveAgentMCPServerAllowlist(definition) toolsRegistry := tools.NewToolRegistry() toolsRegistry.SetAllowlist(agentToolAllowlist) @@ -237,6 +239,7 @@ func NewAgentInstance( Tools: toolsRegistry, Subagents: subagents, SkillsFilter: skillsFilter, + MCPServerAllowlist: agentMCPServerAllowlist, Candidates: candidates, Router: router, LightCandidates: lightCandidates, @@ -295,6 +298,14 @@ func resolveAgentSkillsFilter( return append([]string(nil), agentCfg.Skills...) } +func (a *AgentInstance) AllowsMCPServer(serverName string) bool { + if a == nil || a.MCPServerAllowlist == nil { + return true + } + _, ok := a.MCPServerAllowlist[strings.ToLower(strings.TrimSpace(serverName))] + return ok +} + func compilePatterns(patterns []string) []*regexp.Regexp { compiled := make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index aedda1c32..869e5fbc7 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -287,6 +287,7 @@ func TestNewAgentInstance_UsesFrontmatterModelAndSkills(t *testing.T) { "AGENT.md": `--- model: frontmatter-model skills: [frontmatter-skill] +mcpServers: [GitHub, filesystem] --- # Agent @@ -319,4 +320,13 @@ Use frontmatter identity. if len(agent.SkillsFilter) != 1 || agent.SkillsFilter[0] != "frontmatter-skill" { t.Fatalf("agent.SkillsFilter = %v, want [frontmatter-skill]", agent.SkillsFilter) } + if !agent.AllowsMCPServer("github") { + t.Fatal("expected github MCP server to be allowed from frontmatter") + } + if !agent.AllowsMCPServer("FILESYSTEM") { + t.Fatal("expected filesystem MCP server matching to be case-insensitive") + } + if agent.AllowsMCPServer("slack") { + t.Fatal("expected slack MCP server to be blocked by frontmatter allowlist") + } } diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index 97debbc33..1fad059a4 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -124,6 +124,15 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { if !ok { continue } + if !agent.AllowsMCPServer(serverName) { + logger.DebugCF("agent", "Skipped MCP tool registration by agent mcpServers allowlist", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "tool": tool.Name, + }) + continue + } mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) diff --git a/pkg/agent/loop_mcp_test.go b/pkg/agent/loop_mcp_test.go index 35c3e49c8..baf126bd1 100644 --- a/pkg/agent/loop_mcp_test.go +++ b/pkg/agent/loop_mcp_test.go @@ -7,6 +7,8 @@ package agent import ( + "os" + "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/config" @@ -73,3 +75,50 @@ func TestServerIsDeferred(t *testing.T) { }) } } + +func TestResolveAgentMCPServerAllowlist(t *testing.T) { + workspace := t.TempDir() + agentPath := filepath.Join(workspace, "AGENT.md") + content := `--- +mcpServers: [GitHub, filesystem, github] +--- +# Agent +` + if err := os.WriteFile(agentPath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(AGENT.md) error = %v", err) + } + + allowlist := resolveAgentMCPServerAllowlist(loadAgentDefinition(workspace)) + if len(allowlist) != 2 { + t.Fatalf("len(allowlist) = %d, want 2", len(allowlist)) + } + if _, ok := allowlist["github"]; !ok { + t.Fatal("expected github to be present in MCP allowlist") + } + if _, ok := allowlist["filesystem"]; !ok { + t.Fatal("expected filesystem to be present in MCP allowlist") + } +} + +func TestAgentInstance_AllowsMCPServer(t *testing.T) { + t.Run("nil allowlist allows all", func(t *testing.T) { + agent := &AgentInstance{} + if !agent.AllowsMCPServer("github") { + t.Fatal("expected nil MCP allowlist to allow all servers") + } + }) + + t.Run("explicit allowlist filters servers", func(t *testing.T) { + agent := &AgentInstance{ + MCPServerAllowlist: map[string]struct{}{ + "github": {}, + }, + } + if !agent.AllowsMCPServer("GitHub") { + t.Fatal("expected MCP server matching to be case-insensitive") + } + if agent.AllowsMCPServer("filesystem") { + t.Fatal("expected filesystem to be blocked by MCP allowlist") + } + }) +} diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index 899c84b89..de68352ad 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -26,3 +26,20 @@ func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { sort.Strings(result) return result } + +func resolveAgentMCPServerAllowlist(definition AgentContextDefinition) map[string]struct{} { + if definition.Agent == nil || definition.Agent.Frontmatter.MCPServers == nil { + return nil + } + + allowlist := make(map[string]struct{}, len(definition.Agent.Frontmatter.MCPServers)) + for _, raw := range definition.Agent.Frontmatter.MCPServers { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + continue + } + allowlist[trimmed] = struct{}{} + } + + return allowlist +} From 409251e69dd4f1465c6cf43c6d3607afe0850cd3 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 23:41:32 +0200 Subject: [PATCH 07/71] fix(agent): fail closed on invalid AGENT frontmatter --- pkg/agent/definition.go | 22 ++++++++++++++++------ pkg/agent/instance_test.go | 36 ++++++++++++++++++++++++++++++++++++ pkg/agent/tool_allowlist.go | 16 ++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/pkg/agent/definition.go b/pkg/agent/definition.go index 90a69eaa4..5b0e29137 100644 --- a/pkg/agent/definition.go +++ b/pkg/agent/definition.go @@ -45,6 +45,7 @@ type AgentPromptDefinition struct { Body string `json:"body"` RawFrontmatter string `json:"raw_frontmatter,omitempty"` Frontmatter AgentFrontmatter `json:"frontmatter"` + FrontmatterErr string `json:"frontmatter_error,omitempty"` } // SoulDefinition represents the resolved SOUL.md file linked to the agent. @@ -146,19 +147,21 @@ func loadUserDefinition(workspace string) *UserDefinition { func parseAgentPromptDefinition(path, content string) AgentPromptDefinition { frontmatter, body := splitAgentFrontmatter(content) + parsedFrontmatter, err := parseAgentFrontmatter(path, frontmatter) return AgentPromptDefinition{ Path: path, Raw: content, Body: body, RawFrontmatter: frontmatter, - Frontmatter: parseAgentFrontmatter(path, frontmatter), + Frontmatter: parsedFrontmatter, + FrontmatterErr: errorString(err), } } -func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter { +func parseAgentFrontmatter(path, frontmatter string) (AgentFrontmatter, error) { frontmatter = strings.TrimSpace(frontmatter) if frontmatter == "" { - return AgentFrontmatter{} + return AgentFrontmatter{}, nil } rawFields := make(map[string]any) @@ -167,7 +170,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter { "path": path, "error": err.Error(), }) - return AgentFrontmatter{} + return AgentFrontmatter{}, err } var typed struct { @@ -184,7 +187,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter { "path": path, "error": err.Error(), }) - return AgentFrontmatter{} + return AgentFrontmatter{}, err } return AgentFrontmatter{ @@ -196,7 +199,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter { Skills: append([]string(nil), typed.Skills...), MCPServers: append([]string(nil), typed.MCPServers...), Fields: rawFields, - } + }, nil } func splitAgentFrontmatter(content string) (frontmatter, body string) { @@ -253,3 +256,10 @@ func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 869e5fbc7..3edac0724 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -330,3 +330,39 @@ Use frontmatter identity. t.Fatal("expected slack MCP server to be blocked by frontmatter allowlist") } } + +func TestNewAgentInstance_InvalidFrontmatterFailsClosedForToolsAndMCPServers(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +tools: [read_file +mcpServers: [github] +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "default-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + }, + } + + agent := NewAgentInstance(&config.AgentConfig{ + ID: "research", + Workspace: workspace, + }, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + if _, ok := agent.Tools.Get("read_file"); ok { + t.Fatal("expected malformed frontmatter to fail closed and block read_file") + } + if agent.AllowsMCPServer("github") { + t.Fatal("expected malformed frontmatter to fail closed for MCP servers") + } +} diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index de68352ad..f7434c188 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -6,6 +6,9 @@ import ( ) func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { + if frontmatterParseFailed(definition) { + return []string{} + } if definition.Agent == nil || definition.Agent.Frontmatter.Tools == nil { return nil } @@ -28,6 +31,9 @@ func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { } func resolveAgentMCPServerAllowlist(definition AgentContextDefinition) map[string]struct{} { + if frontmatterParseFailed(definition) { + return map[string]struct{}{} + } if definition.Agent == nil || definition.Agent.Frontmatter.MCPServers == nil { return nil } @@ -43,3 +49,13 @@ func resolveAgentMCPServerAllowlist(definition AgentContextDefinition) map[strin return allowlist } + +func frontmatterParseFailed(definition AgentContextDefinition) bool { + if definition.Agent == nil { + return false + } + if strings.TrimSpace(definition.Agent.RawFrontmatter) == "" { + return false + } + return strings.TrimSpace(definition.Agent.FrontmatterErr) != "" +} From f5f1dc980868e6a88fa3a95e5c4a26cd0db66676 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 23:43:35 +0200 Subject: [PATCH 08/71] fix(agent): load only allowed MCP servers --- pkg/agent/loop_mcp.go | 35 ++++++++++++++++++++++-- pkg/agent/loop_mcp_test.go | 56 ++++++++++++++++++++++++++++++++++++++ pkg/agent/registry.go | 24 ++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index 1fad059a4..c9f3bc03d 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -69,8 +69,18 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { return nil } + mcpCfg := filterMCPConfigServers(al.cfg.Tools.MCP, al.registry.allowedMCPServers()) + if mcpCfg.Servers == nil || len(mcpCfg.Servers) == 0 { + logger.InfoCF( + "agent", + "No MCP servers selected after applying per-agent mcpServers allowlists", + nil, + ) + return nil + } + findValidServer := false - for _, serverCfg := range al.cfg.Tools.MCP.Servers { + for _, serverCfg := range mcpCfg.Servers { if serverCfg.Enabled { findValidServer = true } @@ -89,7 +99,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { workspacePath = defaultAgent.Workspace } - if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + if err := mcpManager.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", map[string]any{ "error": err.Error(), @@ -115,7 +125,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { // Determine whether this server's tools should be deferred (hidden). // Per-server "deferred" field takes precedence over the global Discovery.Enabled. - serverCfg := al.cfg.Tools.MCP.Servers[serverName] + serverCfg := mcpCfg.Servers[serverName] registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg) for _, tool := range conn.Tools { @@ -216,6 +226,25 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { return al.mcp.getInitErr() } +func filterMCPConfigServers( + mcpCfg config.MCPConfig, + allowed map[string]struct{}, +) config.MCPConfig { + if allowed == nil { + return mcpCfg + } + + filtered := mcpCfg + filtered.Servers = make(map[string]config.MCPServerConfig) + for serverName, serverCfg := range mcpCfg.Servers { + if _, ok := allowed[serverName]; ok { + filtered.Servers[serverName] = serverCfg + } + } + + return filtered +} + // serverIsDeferred reports whether an MCP server's tools should be registered // as hidden (deferred/discovery mode). // diff --git a/pkg/agent/loop_mcp_test.go b/pkg/agent/loop_mcp_test.go index baf126bd1..ee00d22ba 100644 --- a/pkg/agent/loop_mcp_test.go +++ b/pkg/agent/loop_mcp_test.go @@ -122,3 +122,59 @@ func TestAgentInstance_AllowsMCPServer(t *testing.T) { } }) } + +func TestAgentRegistry_AllowedMCPServers(t *testing.T) { + t.Run("returns nil when any agent allows all servers", func(t *testing.T) { + registry := &AgentRegistry{ + agents: map[string]*AgentInstance{ + "main": {ID: "main", MCPServerAllowlist: nil}, + "research": {ID: "research", MCPServerAllowlist: map[string]struct{}{"github": {}}}, + }, + } + + if allowed := registry.allowedMCPServers(); allowed != nil { + t.Fatalf("expected nil union when one agent allows all, got %v", allowed) + } + }) + + t.Run("returns union of explicit allowlists", func(t *testing.T) { + registry := &AgentRegistry{ + agents: map[string]*AgentInstance{ + "main": {ID: "main", MCPServerAllowlist: map[string]struct{}{"github": {}}}, + "research": {ID: "research", MCPServerAllowlist: map[string]struct{}{"filesystem": {}}}, + }, + } + + allowed := registry.allowedMCPServers() + if len(allowed) != 2 { + t.Fatalf("len(allowed) = %d, want 2", len(allowed)) + } + if _, ok := allowed["github"]; !ok { + t.Fatal("expected github in allowed MCP server union") + } + if _, ok := allowed["filesystem"]; !ok { + t.Fatal("expected filesystem in allowed MCP server union") + } + }) +} + +func TestFilterMCPConfigServers(t *testing.T) { + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "github": {Enabled: true}, + "filesystem": {Enabled: true}, + }, + } + + filtered := filterMCPConfigServers(mcpCfg, map[string]struct{}{"github": {}}) + if len(filtered.Servers) != 1 { + t.Fatalf("len(filtered.Servers) = %d, want 1", len(filtered.Servers)) + } + if _, ok := filtered.Servers["github"]; !ok { + t.Fatal("expected github server to remain after filtering") + } + if _, ok := filtered.Servers["filesystem"]; ok { + t.Fatal("expected filesystem server to be removed by filtering") + } +} diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index ef5645e51..1eba72250 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -88,6 +88,30 @@ func (r *AgentRegistry) ListAgentIDs() []string { return ids } +func (r *AgentRegistry) allowedMCPServers() map[string]struct{} { + r.mu.RLock() + defer r.mu.RUnlock() + + if len(r.agents) == 0 { + return nil + } + + union := make(map[string]struct{}) + for _, agent := range r.agents { + if agent == nil { + continue + } + if agent.MCPServerAllowlist == nil { + return nil + } + for serverName := range agent.MCPServerAllowlist { + union[serverName] = struct{}{} + } + } + + return union +} + // CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID. func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool { parent, ok := r.GetAgent(parentAgentID) From abeb2d8e0a3023c245d84e65baec0b8492ee1add Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 23:44:41 +0200 Subject: [PATCH 09/71] fix(agent): fall back to first AGENT line for discovery --- pkg/agent/discovery.go | 18 ++++++++++++++++-- pkg/agent/discovery_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index 6cd49f2a6..d08ed1880 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -99,14 +99,28 @@ func descriptorIdentity(agentID string, definition AgentContextDefinition) (stri } if description == "" && - definition.Source == AgentDefinitionSourceAgents && definition.Agent != nil { - description = firstMeaningfulParagraph(definition.Agent.Body) + if definition.Source == AgentDefinitionSourceAgent { + description = firstNonEmptyLine(definition.Agent.Body) + } else if definition.Source == AgentDefinitionSourceAgents { + description = firstMeaningfulParagraph(definition.Agent.Body) + } } return name, description } +func firstNonEmptyLine(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" { + return trimmed + } + } + return "" +} + func firstMeaningfulParagraph(content string) string { content = strings.ReplaceAll(content, "\r\n", "\n") paragraphs := strings.Split(content, "\n\n") diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go index 4dbaea900..28da55e25 100644 --- a/pkg/agent/discovery_test.go +++ b/pkg/agent/discovery_test.go @@ -178,3 +178,30 @@ Generalist. t.Fatalf("did not expect discovery section for singleton registry, got %q", systemPrompt) } } + +func TestAgentRegistry_ListAgentsFallsBackToFirstNonEmptyAgentLine(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: Research Agent +--- + + +First useful line. +Second line. +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := testCfg([]config.AgentConfig{ + {ID: "research", Default: true, Workspace: workspace}, + }) + + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + descriptor, ok := registry.GetAgentDescriptor("research") + if !ok || descriptor == nil { + t.Fatal("expected research descriptor lookup to succeed") + } + if descriptor.Description != "First useful line." { + t.Fatalf("descriptor.Description = %q, want %q", descriptor.Description, "First useful line.") + } +} From 765a165475b77e6744631533badb6de170c17f99 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sun, 29 Mar 2026 23:48:06 +0200 Subject: [PATCH 10/71] fix(agent): warn on unknown frontmatter capabilities --- pkg/agent/instance.go | 1 + pkg/agent/tool_allowlist.go | 143 +++++++++++++++++++++++++++++-- pkg/agent/tool_allowlist_test.go | 58 +++++++++++++ 3 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 pkg/agent/tool_allowlist_test.go diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index f95a165af..2df65b905 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -135,6 +135,7 @@ func NewAgentInstance( subagents = agentCfg.Subagents skillsFilter = resolveAgentSkillsFilter(agentCfg, definition) } + warnOnUnknownAgentDeclarations(agentID, workspace, cfg, definition) maxIter := defaults.MaxToolIterations if maxIter == 0 { diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index f7434c188..b220f1903 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -3,8 +3,144 @@ package agent import ( "sort" "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) +const dynamicMCPToolPrefix = "mcp_" + +func warnOnUnknownAgentDeclarations( + agentID, workspace string, + cfg *config.Config, + definition AgentContextDefinition, +) { + if cfg == nil || frontmatterParseFailed(definition) { + return + } + + if unknownTools := unknownAgentToolNames(cfg, definition); len(unknownTools) > 0 { + logger.WarnCF("agent", "AGENT.md declares unknown tool names", + map[string]any{ + "agent_id": agentID, + "workspace": workspace, + "tools": unknownTools, + }) + } + + if unknownServers := unknownAgentMCPServerNames(cfg, definition); len(unknownServers) > 0 { + logger.WarnCF("agent", "AGENT.md declares unknown MCP server names", + map[string]any{ + "agent_id": agentID, + "workspace": workspace, + "mcp_servers": unknownServers, + }) + } +} + +func unknownAgentToolNames(cfg *config.Config, definition AgentContextDefinition) []string { + if definition.Agent == nil || definition.Agent.Frontmatter.Tools == nil { + return nil + } + + known := knownRuntimeToolNames(cfg) + unknown := make(map[string]struct{}) + for _, raw := range definition.Agent.Frontmatter.Tools { + name := strings.ToLower(strings.TrimSpace(raw)) + if name == "" || strings.HasPrefix(name, dynamicMCPToolPrefix) { + continue + } + if _, ok := known[name]; ok { + continue + } + unknown[name] = struct{}{} + } + + return sortedKeys(unknown) +} + +func unknownAgentMCPServerNames(cfg *config.Config, definition AgentContextDefinition) []string { + if cfg == nil || definition.Agent == nil || definition.Agent.Frontmatter.MCPServers == nil { + return nil + } + + unknown := make(map[string]struct{}) + for _, raw := range definition.Agent.Frontmatter.MCPServers { + name := strings.ToLower(strings.TrimSpace(raw)) + if name == "" { + continue + } + if _, ok := cfg.Tools.MCP.Servers[name]; ok { + continue + } + unknown[name] = struct{}{} + } + + return sortedKeys(unknown) +} + +func knownRuntimeToolNames(cfg *config.Config) map[string]struct{} { + known := make(map[string]struct{}) + if cfg == nil { + return known + } + + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("read_file"), "read_file") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("write_file"), "write_file") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("list_dir"), "list_dir") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("exec"), "exec") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("edit_file"), "edit_file") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("append_file"), "append_file") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("cron"), "cron") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("web"), "web_search") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("web_fetch"), "web_fetch") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("i2c"), "i2c") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("spi"), "spi") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("message"), "message") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("send_file"), "send_file") + addKnownToolIfEnabled( + known, + cfg.Tools.IsToolEnabled("skills") && cfg.Tools.IsToolEnabled("find_skills"), + "find_skills", + ) + addKnownToolIfEnabled( + known, + cfg.Tools.IsToolEnabled("skills") && cfg.Tools.IsToolEnabled("install_skill"), + "install_skill", + ) + if cfg.Tools.IsToolEnabled("subagent") { + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("spawn"), "spawn") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("subagent"), "subagent") + addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("spawn_status"), "spawn_status") + } + if cfg.Tools.IsToolEnabled("mcp") && cfg.Tools.MCP.Discovery.Enabled { + addKnownToolIfEnabled(known, cfg.Tools.MCP.Discovery.UseRegex, "tool_search_tool_regex") + addKnownToolIfEnabled(known, cfg.Tools.MCP.Discovery.UseBM25, "tool_search_tool_bm25") + } + + return known +} + +func addKnownToolIfEnabled(known map[string]struct{}, enabled bool, name string) { + if !enabled { + return + } + known[name] = struct{}{} +} + +func sortedKeys(values map[string]struct{}) []string { + if len(values) == 0 { + return nil + } + + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { if frontmatterParseFailed(definition) { return []string{} @@ -22,12 +158,7 @@ func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { allowlist[trimmed] = struct{}{} } - result := make([]string, 0, len(allowlist)) - for name := range allowlist { - result = append(result, name) - } - sort.Strings(result) - return result + return sortedKeys(allowlist) } func resolveAgentMCPServerAllowlist(definition AgentContextDefinition) map[string]struct{} { diff --git a/pkg/agent/tool_allowlist_test.go b/pkg/agent/tool_allowlist_test.go new file mode 100644 index 000000000..059ee9344 --- /dev/null +++ b/pkg/agent/tool_allowlist_test.go @@ -0,0 +1,58 @@ +package agent + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestUnknownAgentToolNames(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +tools: [read_file, web_serach, mcp_github_search] +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + Web: config.WebToolsConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + }, + }, + } + + unknown := unknownAgentToolNames(cfg, loadAgentDefinition(workspace)) + if len(unknown) != 1 || unknown[0] != "web_serach" { + t.Fatalf("unknownAgentToolNames() = %v, want [web_serach]", unknown) + } +} + +func TestUnknownAgentMCPServerNames(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +mcpServers: [github, githb] +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + Servers: map[string]config.MCPServerConfig{ + "github": {Enabled: true}, + }, + }, + }, + } + + unknown := unknownAgentMCPServerNames(cfg, loadAgentDefinition(workspace)) + if len(unknown) != 1 || unknown[0] != "githb" { + t.Fatalf("unknownAgentMCPServerNames() = %v, want [githb]", unknown) + } +} From fe51cd504fac4b49904942a75839a7a5aa73f146 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Wed, 8 Apr 2026 00:38:55 +0900 Subject: [PATCH 11/71] refactor(line): use official LINE Bot SDK v8 Replace hand-rolled HTTP/HMAC/JSON code (~270 lines) with the official line-bot-sdk-go v8, reducing maintenance burden and eliminating potential bugs in signature verification, request construction, and response parsing. This continues the work started in #500 by @xiaket, addressing all review feedback and rebasing onto current main. Changes: - Replace bytes/crypto/json/io imports with line-bot-sdk-go/v8 - Use webhook.ParseRequest for body reading + signature verification - Use messaging_api.MessagingApiAPI for ReplyMessage/PushMessage/ShowLoadingAnimation/GetBotInfo - Type-switch on webhook.MessageEvent message types (TextMessageContent, ImageMessageContent, etc.) instead of JSON unmarshalling - Type-switch on webhook.SourceInterface (UserSource/GroupSource/RoomSource) - Type-switch on webhook.Mentionee (UserMentionee/AllMentionee) Review feedback addressed (from #500): - Use WithContext(ctx) on all SDK calls to preserve cancellation/timeout - Fix variable shadowing of isMentioned (declared at function scope) - Remove reflect-based message ID extraction (use type switch + msg.Id) - Use mentionee.IsSelf for cleaner bot mention detection - Preserve body size security check via http.MaxBytesReader before webhook.ParseRequest (compatible with #1413) All existing tests pass without modification. --- go.mod | 1 + go.sum | 2 + pkg/channels/line/line.go | 466 ++++++++++++++------------------------ 3 files changed, 174 insertions(+), 295 deletions(-) diff --git a/go.mod b/go.mod index a9f4bb7cb..2cd09df0f 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( 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/line/line-bot-sdk-go/v8 v8.19.0 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 github.com/modelcontextprotocol/go-sdk v1.4.1 diff --git a/go.sum b/go.sum index 765a3211a..fe33d992b 100644 --- a/go.sum +++ b/go.sum @@ -175,6 +175,8 @@ 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/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4= +github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 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= diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 230983935..3de2397be 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -1,19 +1,17 @@ package line import ( - "bytes" "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" + "errors" "fmt" - "io" "net/http" "strings" "sync" "time" + "github.com/line/line-bot-sdk-go/v8/linebot/messaging_api" + "github.com/line/line-bot-sdk-go/v8/linebot/webhook" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -24,13 +22,7 @@ import ( ) const ( - lineAPIBase = "https://api.line.me/v2/bot" - lineDataAPIBase = "https://api-data.line.me/v2/bot" - lineReplyEndpoint = lineAPIBase + "/message/reply" - linePushEndpoint = lineAPIBase + "/message/push" - lineContentEndpoint = lineDataAPIBase + "/message/%s/content" - lineBotInfoEndpoint = lineAPIBase + "/info" - lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" + lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content" lineReplyTokenMaxAge = 25 * time.Second // Limit request body to prevent memory exhaustion (DoS). @@ -45,17 +37,16 @@ type replyTokenEntry struct { // LINEChannel implements the Channel interface for LINE Official Account // using the LINE Messaging API with HTTP webhook for receiving messages -// and REST API for sending messages. +// and the official LINE Bot SDK for sending messages. type LINEChannel struct { *channels.BaseChannel config config.LINEConfig - infoClient *http.Client // for bot info lookups (short timeout) - apiClient *http.Client // for messaging API calls - botUserID string // Bot's user ID - botBasicID string // Bot's basic ID (e.g. @216ru...) - botDisplayName string // Bot's display name for text-based mention detection - replyTokens sync.Map // chatID -> replyTokenEntry - quoteTokens sync.Map // chatID -> quoteToken (string) + client *messaging_api.MessagingApiAPI + botUserID string // Bot's user ID + botBasicID string // Bot's basic ID (e.g. @216ru...) + botDisplayName string // Bot's display name for text-based mention detection + replyTokens sync.Map // chatID -> replyTokenEntry + quoteTokens sync.Map // chatID -> quoteToken (string) ctx context.Context cancel context.CancelFunc } @@ -66,6 +57,11 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } + client, err := messaging_api.NewMessagingApiAPI(cfg.ChannelAccessToken.String()) + if err != nil { + return nil, fmt.Errorf("failed to create LINE messaging client: %w", err) + } + base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(5000), channels.WithGroupTrigger(cfg.GroupTrigger), @@ -75,8 +71,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha return &LINEChannel{ BaseChannel: base, config: cfg, - infoClient: &http.Client{Timeout: 10 * time.Second}, - apiClient: &http.Client{Timeout: 30 * time.Second}, + client: client, }, nil } @@ -87,11 +82,15 @@ func (c *LINEChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) // Fetch bot profile to get bot's userId for mention detection - if err := c.fetchBotInfo(); err != nil { + info, err := c.client.WithContext(ctx).GetBotInfo() + if err != nil { logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ "error": err.Error(), }) } else { + c.botUserID = info.UserId + c.botBasicID = info.BasicId + c.botDisplayName = info.DisplayName logger.InfoCF("line", "Bot info fetched", map[string]any{ "bot_user_id": c.botUserID, "basic_id": c.botBasicID, @@ -104,39 +103,6 @@ func (c *LINEChannel) Start(ctx context.Context) error { return nil } -// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API. -func (c *LINEChannel) fetchBotInfo() error { - req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) - - resp, err := c.infoClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("bot info API returned status %d", resp.StatusCode) - } - - var info struct { - UserID string `json:"userId"` - BasicID string `json:"basicId"` - DisplayName string `json:"displayName"` - } - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { - return err - } - - c.botUserID = info.UserID - c.botBasicID = info.BasicID - c.botDisplayName = info.DisplayName - return nil -} - // Stop gracefully stops the LINE channel. func (c *LINEChannel) Stop(ctx context.Context) error { logger.InfoC("line", "Stopping LINE channel") @@ -170,140 +136,69 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { return } - body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) + // Limit body size to prevent memory exhaustion (DoS). + // ParseRequest reads r.Body internally via io.ReadAll; wrapping with + // MaxBytesReader ensures oversized payloads are rejected before full + // allocation. + r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize) + + cb, err := webhook.ParseRequest(c.config.ChannelSecret.String(), r) if err != nil { - logger.ErrorCF("line", "Failed to read request body", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - if int64(len(body)) > maxWebhookBodySize { - logger.WarnC("line", "Webhook request body too large, rejected") - http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) - return - } - - signature := r.Header.Get("X-Line-Signature") - if !c.verifySignature(body, signature) { - logger.WarnC("line", "Invalid webhook signature") - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - var payload struct { - Events []lineEvent `json:"events"` - } - if err := json.Unmarshal(body, &payload); err != nil { - logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Bad request", http.StatusBadRequest) + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + logger.WarnC("line", "Webhook request body too large, rejected") + http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) + } else if errors.Is(err, webhook.ErrInvalidSignature) { + logger.WarnC("line", "Invalid webhook signature") + http.Error(w, "Forbidden", http.StatusForbidden) + } else { + logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + } return } // Return 200 immediately, process events asynchronously w.WriteHeader(http.StatusOK) - for _, event := range payload.Events { + for _, event := range cb.Events { go c.processEvent(event) } } -// verifySignature validates the X-Line-Signature using HMAC-SHA256. -func (c *LINEChannel) verifySignature(body []byte, signature string) bool { - if signature == "" { - return false - } - - mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String())) - mac.Write(body) - expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - - return hmac.Equal([]byte(expected), []byte(signature)) -} - -// LINE webhook event types -type lineEvent struct { - Type string `json:"type"` - ReplyToken string `json:"replyToken"` - Source lineSource `json:"source"` - Message json.RawMessage `json:"message"` - Timestamp int64 `json:"timestamp"` -} - -type lineSource struct { - Type string `json:"type"` // "user", "group", "room" - UserID string `json:"userId"` - GroupID string `json:"groupId"` - RoomID string `json:"roomId"` -} - -type lineMessage struct { - ID string `json:"id"` - Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker" - Text string `json:"text"` - QuoteToken string `json:"quoteToken"` - Mention *struct { - Mentionees []lineMentionee `json:"mentionees"` - } `json:"mention"` - ContentProvider struct { - Type string `json:"type"` - } `json:"contentProvider"` -} - -type lineMentionee struct { - Index int `json:"index"` - Length int `json:"length"` - Type string `json:"type"` // "user", "all" - UserID string `json:"userId"` -} - -func (c *LINEChannel) processEvent(event lineEvent) { - if event.Type != "message" { +func (c *LINEChannel) processEvent(event webhook.EventInterface) { + msgEvent, ok := event.(webhook.MessageEvent) + if !ok { logger.DebugCF("line", "Ignoring non-message event", map[string]any{ - "type": event.Type, + "type": event.GetType(), }) return } - senderID := event.Source.UserID - chatID := c.resolveChatID(event.Source) - isGroup := event.Source.Type == "group" || event.Source.Type == "room" - - var msg lineMessage - if err := json.Unmarshal(event.Message, &msg); err != nil { - logger.ErrorCF("line", "Failed to parse message", map[string]any{ - "error": err.Error(), - }) - return - } + senderID, chatID, sourceType := c.resolveSource(msgEvent.Source) + isGroup := sourceType == "group" || sourceType == "room" // Store reply token for later use - if event.ReplyToken != "" { + if msgEvent.ReplyToken != "" { c.replyTokens.Store(chatID, replyTokenEntry{ - token: event.ReplyToken, + token: msgEvent.ReplyToken, timestamp: time.Now(), }) } - // Store quote token for quoting the original message in reply - if msg.QuoteToken != "" { - c.quoteTokens.Store(chatID, msg.QuoteToken) - } - var content string var mediaPaths []string - - scope := channels.BuildMediaScope("line", chatID, msg.ID) + var messageID string + var isMentioned bool // Helper to register a local file with the media store - storeMedia := func(localPath, filename string) string { + storeMedia := func(localPath, filename, scope string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "line", - CleanupPolicy: media.CleanupPolicyDeleteOnCleanup, + Filename: filename, + Source: "line", }, scope) if err == nil { return ref @@ -312,37 +207,51 @@ func (c *LINEChannel) processEvent(event lineEvent) { return localPath // fallback } - switch msg.Type { - case "text": + switch msg := msgEvent.Message.(type) { + case webhook.TextMessageContent: + messageID = msg.Id content = msg.Text + isMentioned = c.isBotMentioned(msg) + // Store quote token for quoting the original message in reply + if msg.QuoteToken != "" { + c.quoteTokens.Store(chatID, msg.QuoteToken) + } // Strip bot mention from text in group chats if isGroup { content = c.stripBotMention(content, msg) } - case "image": - localPath := c.downloadContent(msg.ID, "image.jpg") - if localPath != "" { - mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg")) + case webhook.ImageMessageContent: + messageID = msg.Id + if localPath := c.downloadContent(msg.Id, "image.jpg"); localPath != "" { + scope := channels.BuildMediaScope("line", chatID, msg.Id) + mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg", scope)) content = "[image]" } - case "audio": - localPath := c.downloadContent(msg.ID, "audio.m4a") - if localPath != "" { - mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a")) + case webhook.AudioMessageContent: + messageID = msg.Id + if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" { + scope := channels.BuildMediaScope("line", chatID, msg.Id) + mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope)) content = "[audio]" } - case "video": - localPath := c.downloadContent(msg.ID, "video.mp4") - if localPath != "" { - mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4")) + case webhook.VideoMessageContent: + messageID = msg.Id + if localPath := c.downloadContent(msg.Id, "video.mp4"); localPath != "" { + scope := channels.BuildMediaScope("line", chatID, msg.Id) + mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4", scope)) content = "[video]" } - case "file": + case webhook.FileMessageContent: + messageID = msg.Id content = "[file]" - case "sticker": + case webhook.StickerMessageContent: + messageID = msg.Id content = "[sticker]" default: - content = fmt.Sprintf("[%s]", msg.Type) + logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{ + "type": msgEvent.Message.GetType(), + }) + return } if strings.TrimSpace(content) == "" { @@ -351,7 +260,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { // In group chats, apply unified group trigger filtering if isGroup { - isMentioned := c.isBotMentioned(msg) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ @@ -364,7 +272,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { metadata := map[string]string{ "platform": "line", - "source_type": event.Source.Type, + "source_type": sourceType, } var peer bus.Peer @@ -377,7 +285,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, - "message_type": msg.Type, + "message_type": msgEvent.Message.GetType(), "is_group": isGroup, "preview": utils.Truncate(content, 50), }) @@ -392,34 +300,32 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) + c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } // isBotMentioned checks if the bot is mentioned in the message. -// It first checks the mention metadata (userId match), then falls back +// It first checks the mention metadata (userId match or IsSelf), then falls back // to text-based detection using the bot's display name, since LINE may // not include userId in mentionees for Official Accounts. -func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { - // Check mention metadata +func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool { if msg.Mention != nil { for _, m := range msg.Mention.Mentionees { - if m.Type == "all" { + switch mentionee := m.(type) { + case webhook.AllMentionee: return true - } - if c.botUserID != "" && m.UserID == c.botUserID { - return true - } - } - // Mention metadata exists with mentionees but bot not matched by userId. - // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), - // so check if any mentionee overlaps with bot display name in text. - if c.botDisplayName != "" { - for _, m := range msg.Mention.Mentionees { - if m.Index >= 0 && m.Length > 0 { + case webhook.UserMentionee: + if mentionee.IsSelf { + return true + } + if c.botUserID != "" && mentionee.UserId == c.botUserID { + return true + } + // Check if mentionee text overlaps with bot display name + if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 { runes := []rune(msg.Text) - end := m.Index + m.Length + end := int(mentionee.Index) + int(mentionee.Length) if end <= len(runes) { - mentionText := string(runes[m.Index:end]) + mentionText := string(runes[mentionee.Index:end]) if strings.Contains(mentionText, c.botDisplayName) { return true } @@ -438,30 +344,43 @@ func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { } // stripBotMention removes the @BotName mention text from the message. -func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { +func (c *LINEChannel) stripBotMention(text string, msg webhook.TextMessageContent) string { stripped := false - // Try to strip using mention metadata indices if msg.Mention != nil { runes := []rune(text) for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { m := msg.Mention.Mentionees[i] - // Strip if userId matches OR if the mention text contains the bot display name shouldStrip := false - if c.botUserID != "" && m.UserID == c.botUserID { - shouldStrip = true - } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { - end := m.Index + m.Length - if end <= len(runes) { - mentionText := string(runes[m.Index:end]) - if strings.Contains(mentionText, c.botDisplayName) { - shouldStrip = true + var index, length int32 + + switch mentionee := m.(type) { + case webhook.UserMentionee: + index = mentionee.Index + length = mentionee.Length + if mentionee.IsSelf { + shouldStrip = true + } else if c.botUserID != "" && mentionee.UserId == c.botUserID { + shouldStrip = true + } else if c.botDisplayName != "" && index >= 0 && length > 0 { + end := int(index) + int(length) + if end <= len(runes) { + mentionText := string(runes[index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + shouldStrip = true + } } } + case webhook.AllMentionee: + // Don't strip @All mentions + continue + default: + continue } + if shouldStrip { - start := m.Index - end := m.Index + m.Length + start := int(index) + end := int(index) + int(length) if start >= 0 && end <= len(runes) { runes = append(runes[:start], runes[end:]...) stripped = true @@ -481,16 +400,20 @@ func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { return strings.TrimSpace(text) } -// resolveChatID determines the chat ID from the event source. -// For group/room messages, use the group/room ID; for 1:1, use the user ID. -func (c *LINEChannel) resolveChatID(source lineSource) string { - switch source.Type { - case "group": - return source.GroupID - case "room": - return source.RoomID +// resolveSource extracts senderID, chatID, and source type from the event source. +func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) { + switch src := source.(type) { + case webhook.GroupSource: + return src.UserId, src.GroupId, "group" + case webhook.RoomSource: + return src.UserId, src.RoomId, "room" + case webhook.UserSource: + return src.UserId, src.UserId, "user" default: - return source.UserID + logger.WarnCF("line", "Unknown source type", map[string]any{ + "type": fmt.Sprintf("%T", source), + }) + return "", "", "unknown" } } @@ -507,11 +430,20 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri quoteToken = qt.(string) } + textMsg := messaging_api.TextMessage{ + Text: msg.Content, + QuoteToken: quoteToken, + } + // Try reply token first (free, valid for ~25 seconds) if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { - if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { + _, err := c.client.WithContext(ctx).ReplyMessage(&messaging_api.ReplyMessageRequest{ + ReplyToken: tokenEntry.token, + Messages: []messaging_api.MessageInterface{&textMsg}, + }) + if err == nil { logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, "quoted": quoteToken != "", @@ -523,7 +455,11 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri } // Fall back to Push API - return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) + _, err := c.client.WithContext(ctx).PushMessage(&messaging_api.PushMessageRequest{ + To: msg.ChatID, + Messages: []messaging_api.MessageInterface{&textMsg}, + }, "") + return nil, err } // SendMedia implements the channels.MediaSender interface. @@ -548,7 +484,11 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) } - if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { + textMsg := messaging_api.TextMessage{Text: caption} + if _, err := c.client.WithContext(ctx).PushMessage(&messaging_api.PushMessageRequest{ + To: msg.ChatID, + Messages: []messaging_api.MessageInterface{&textMsg}, + }, ""); err != nil { return nil, err } } @@ -556,38 +496,6 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag return nil, nil } -// buildTextMessage creates a text message object, optionally with quoteToken. -func buildTextMessage(content, quoteToken string) map[string]string { - msg := map[string]string{ - "type": "text", - "text": content, - } - if quoteToken != "" { - msg["quoteToken"] = quoteToken - } - return msg -} - -// sendReply sends a message using the LINE Reply API. -func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { - payload := map[string]any{ - "replyToken": replyToken, - "messages": []map[string]string{buildTextMessage(content, quoteToken)}, - } - - return c.callAPI(ctx, lineReplyEndpoint, payload) -} - -// sendPush sends a message using the LINE Push API. -func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { - payload := map[string]any{ - "to": to, - "messages": []map[string]string{buildTextMessage(content, quoteToken)}, - } - - return c.callAPI(ctx, linePushEndpoint, payload) -} - // StartTyping implements channels.TypingCapable using LINE's loading animation. // // NOTE: The LINE loading animation API only works for 1:1 chats. @@ -635,46 +543,14 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { - payload := map[string]any{ - "chatId": chatID, - "loadingSeconds": 60, - } - return c.callAPI(ctx, lineLoadingEndpoint, payload) + _, err := c.client.WithContext(ctx).ShowLoadingAnimation(&messaging_api.ShowLoadingAnimationRequest{ + ChatId: chatID, + LoadingSeconds: 60, + }) + return err } -// callAPI makes an authenticated POST request to the LINE API. -func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error { - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) - - resp, err := c.apiClient.Do(req) - if err != nil { - return channels.ClassifyNetError(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err)) - } - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) - } - - return nil -} - -// downloadContent downloads media content from the LINE API. +// downloadContent downloads media content from the LINE content API. func (c *LINEChannel) downloadContent(messageID, filename string) string { url := fmt.Sprintf(lineContentEndpoint, messageID) return utils.DownloadFile(url, filename, utils.DownloadOptions{ From c47f5fd2c43f7d797f4004019e65fea720d75681 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 21:27:13 +0800 Subject: [PATCH 12/71] feat(agent): add TargetAgentID to SubTurnConfig for cross-agent delegation When TargetAgentID is set, spawnSubTurn resolves the target AgentInstance from the registry and uses it as the base for the child turn. This gives the child turn the target's workspace, model, tools, and system prompt instead of inheriting from the caller. Model validation is relaxed: empty Model is accepted when TargetAgentID provides the model implicitly via the resolved agent instance. Ref: #2148 --- pkg/agent/subturn.go | 33 ++++++++++++++++++++++++--------- pkg/tools/subagent.go | 1 + 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 9ee7b15c9..61d25d248 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -172,7 +172,10 @@ type SubTurnConfig struct { // Used by team tool to enforce token limits across all team members. InitialTokenBudget *atomic.Int64 - // Can be extended with temperature, topP, etc. + // TargetAgentID, when set, runs the sub-turn as the specified agent. + // The target agent's workspace, model, tools, and system prompt are used + // instead of the caller's. If empty, the sub-turn runs as the parent agent. + TargetAgentID string } // ====================== Context Keys ====================== @@ -230,6 +233,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn( Critical: cfg.Critical, Timeout: cfg.Timeout, MaxContextRunes: cfg.MaxContextRunes, + TargetAgentID: cfg.TargetAgentID, } return spawnSubTurn(ctx, s.al, parentTS, agentCfg) @@ -312,8 +316,9 @@ func spawnSubTurn( return nil, ErrDepthLimitExceeded } - // 2. Config validation - if cfg.Model == "" { + // 2. Config validation: Model is required unless TargetAgentID is set + // (the target agent provides its own model). + if cfg.Model == "" && cfg.TargetAgentID == "" { return nil, ErrInvalidSubTurnConfig } @@ -331,12 +336,22 @@ func spawnSubTurn( childID := al.generateSubTurnID() - // Get the agent instance from parent, falling back to the default agent. - // Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store - // so that child turns never pollute or persist to the parent's session history. - baseAgent := parentTS.agent - if baseAgent == nil { - baseAgent = al.registry.GetDefaultAgent() + // Resolve the agent instance for the child turn. + // When TargetAgentID is set, look up that agent from the registry so the + // child runs with the target's workspace, model, tools, and system prompt. + // Otherwise fall back to the parent's agent (existing behavior). + var baseAgent *AgentInstance + if cfg.TargetAgentID != "" { + var ok bool + baseAgent, ok = al.registry.GetAgent(cfg.TargetAgentID) + if !ok { + return nil, fmt.Errorf("target agent %q not found in registry", cfg.TargetAgentID) + } + } else { + baseAgent = parentTS.agent + if baseAgent == nil { + baseAgent = al.registry.GetDefaultAgent() + } } if baseAgent == nil { return nil, errors.New("parent turnState has no agent instance") diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index ada89efb7..feeabe536 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -30,6 +30,7 @@ type SubTurnConfig struct { ActualSystemPrompt string InitialMessages []providers.Message InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget + TargetAgentID string // If set, run as this agent (its workspace, model, tools) } type SubagentTask struct { From c8335bfd47c83401c674b7fa7f772d7a4a17aabe Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 21:27:39 +0800 Subject: [PATCH 13/71] test(agent): verify TargetAgentID resolves to correct agent instance Add multi-agent test setup (newMultiAgentLoop) with two agents using distinct models (model-alpha, model-beta). Three new tests: - UsesTargetAgent: parent=alpha delegates to beta, event log confirms child runs as agent_id=beta with model=model-beta - NotFound: TargetAgentID pointing to nonexistent agent returns error - EmptyModelAccepted: empty Model field accepted when TargetAgentID provides the model implicitly Ref: #2148 --- pkg/agent/subturn_test.go | 150 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 6a2ba835d..b3015149e 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" + "strings" "sync" "testing" "time" @@ -2065,3 +2068,150 @@ func TestSubTurn_IndependentContext(t *testing.T) { t.Log("✓ SubTurn completed successfully (independent context)") } } + +// ====================== TargetAgentID Tests ====================== + +// newMultiAgentLoop creates an AgentLoop with two named agents for testing +// cross-agent delegation via TargetAgentID. +func newMultiAgentLoop(t *testing.T) (*AgentLoop, func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "multiagent-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + + alphaDir := filepath.Join(tmpDir, "alpha") + betaDir := filepath.Join(tmpDir, "beta") + os.MkdirAll(alphaDir, 0o755) + os.MkdirAll(betaDir, 0o755) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "default-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + List: []config.AgentConfig{ + { + ID: "alpha", + Workspace: alphaDir, + Model: &config.AgentModelConfig{Primary: "model-alpha"}, + }, + { + ID: "beta", + Workspace: betaDir, + Model: &config.AgentModelConfig{Primary: "model-beta"}, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + return al, func() { os.RemoveAll(tmpDir) } +} + +func TestSpawnSubTurn_TargetAgentID_UsesTargetAgent(t *testing.T) { + al, cleanup := newMultiAgentLoop(t) + defer cleanup() + + alphaAgent, ok := al.registry.GetAgent("alpha") + if !ok { + t.Fatal("alpha agent not in registry") + } + betaAgent, ok := al.registry.GetAgent("beta") + if !ok { + t.Fatal("beta agent not in registry") + } + + // Parent is alpha, target is beta + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("spawnSubTurn failed: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + + // Verify the two agents have distinct models (test setup sanity check) + if alphaAgent.Model == betaAgent.Model { + t.Fatal("test setup error: alpha and beta should have different models") + } +} + +func TestSpawnSubTurn_TargetAgentID_NotFound(t *testing.T) { + al, cleanup := newMultiAgentLoop(t) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + _, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + TargetAgentID: "nonexistent", + SystemPrompt: "task", + }) + + if err == nil { + t.Fatal("expected error for nonexistent agent") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention 'not found', got: %v", err) + } +} + +func TestSpawnSubTurn_TargetAgentID_EmptyModelAccepted(t *testing.T) { + al, cleanup := newMultiAgentLoop(t) + defer cleanup() + + alphaAgent, _ := al.registry.GetAgent("alpha") + parent := &turnState{ + ctx: context.Background(), + turnID: "parent-alpha", + depth: 0, + childTurnIDs: []string{}, + pendingResults: make(chan *tools.ToolResult, 4), + concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns), + session: &ephemeralSessionStore{}, + agent: alphaAgent, + } + + // Model is empty but TargetAgentID is set — should NOT fail validation + result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{ + Model: "", // intentionally empty + TargetAgentID: "beta", + SystemPrompt: "task for beta", + }) + if err != nil { + t.Fatalf("should accept empty Model when TargetAgentID is set, got: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } +} From 484ef399f1bcb77cb80cfb77feee784a4afa1b66 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 21:28:31 +0800 Subject: [PATCH 14/71] feat(tools): add delegate tool for synchronous cross-agent task handoff delegate(agent_id, task) hands off a task to a named agent and blocks until the result is ready. The target agent runs with its own config via the TargetAgentID mechanism in SubTurnConfig. Key behaviors: - Self-delegation explicitly rejected - Permission gated by subagents.allow_agents (D4) - Spawner errors preserve the underlying error via WithError - Nil result from spawner handled gracefully - Response attributed with target agent ID Ref: #2148 --- pkg/tools/delegate.go | 101 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 pkg/tools/delegate.go diff --git a/pkg/tools/delegate.go b/pkg/tools/delegate.go new file mode 100644 index 000000000..8831ffeb3 --- /dev/null +++ b/pkg/tools/delegate.go @@ -0,0 +1,101 @@ +package tools + +import ( + "context" + "fmt" + "strings" +) + +// DelegateTool delegates a task to a specific named agent and waits for +// the result. Unlike spawn (async, fire-and-forget) or subagent (sync but +// generic), delegate targets a named agent and runs the task using that +// agent's own workspace, model, and tools. +type DelegateTool struct { + spawner SubTurnSpawner + allowlistCheck func(targetAgentID string) bool + selfAgentID string +} + +func NewDelegateTool() *DelegateTool { + return &DelegateTool{} +} + +func (t *DelegateTool) SetSpawner(spawner SubTurnSpawner) { + t.spawner = spawner +} + +func (t *DelegateTool) SetAllowlistChecker(check func(targetAgentID string) bool) { + t.allowlistCheck = check +} + +func (t *DelegateTool) SetSelfAgentID(id string) { + t.selfAgentID = id +} + +func (t *DelegateTool) Name() string { + return "delegate" +} + +func (t *DelegateTool) Description() string { + return "Delegate a task to another agent and wait for the result. " + + "Use this when another agent is better suited to handle a specific task " + + "based on their capabilities. The target agent runs with its own workspace, " + + "model, and tools." +} + +func (t *DelegateTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "agent_id": map[string]any{ + "type": "string", + "description": "The ID of the target agent to delegate the task to", + }, + "task": map[string]any{ + "type": "string", + "description": "Clear description of the task to delegate", + }, + }, + "required": []string{"agent_id", "task"}, + } +} + +func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + agentID, _ := args["agent_id"].(string) + if strings.TrimSpace(agentID) == "" { + return ErrorResult("agent_id is required and must be a non-empty string") + } + + task, _ := args["task"].(string) + if strings.TrimSpace(task) == "" { + return ErrorResult("task is required and must be a non-empty string") + } + + if t.selfAgentID != "" && agentID == t.selfAgentID { + return ErrorResult("cannot delegate to self") + } + + if t.allowlistCheck != nil && !t.allowlistCheck(agentID) { + return ErrorResult(fmt.Sprintf("not allowed to delegate to agent %q", agentID)) + } + + if t.spawner == nil { + return ErrorResult("delegate tool not configured") + } + + result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{ + TargetAgentID: agentID, + SystemPrompt: task, + Async: false, + }) + if err != nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q failed: %v", agentID, err)).WithError(err) + } + if result == nil { + return ErrorResult(fmt.Sprintf("delegation to agent %q returned no result", agentID)) + } + + result.ForLLM = fmt.Sprintf("[Response from agent %q]\n%s", agentID, result.ForLLM) + + return result +} From 0ff78fa53f453a31a665a079076db7da8b5d72e5 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 21:28:54 +0800 Subject: [PATCH 15/71] test(tools): add delegate tool unit tests 12 test cases covering: - success path with result attribution - agent_id validation (missing, empty, whitespace, wrong type) - task validation (missing, empty, whitespace) - permission denied / allowed via allowlist checker - self-delegation blocked - nil spawner, spawner error, nil result from spawner - open access when no allowlist checker is set Ref: #2148 --- pkg/tools/delegate_test.go | 280 +++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 pkg/tools/delegate_test.go diff --git a/pkg/tools/delegate_test.go b/pkg/tools/delegate_test.go new file mode 100644 index 000000000..f1b4c456f --- /dev/null +++ b/pkg/tools/delegate_test.go @@ -0,0 +1,280 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// delegateMockSpawner records the config and returns a canned result. +type delegateMockSpawner struct { + lastCfg SubTurnConfig + result *ToolResult + err error +} + +func (m *delegateMockSpawner) SpawnSubTurn(_ context.Context, cfg SubTurnConfig) (*ToolResult, error) { + m.lastCfg = cfg + if m.err != nil { + return nil, m.err + } + if m.result != nil { + return m.result, nil + } + return &ToolResult{ + ForLLM: "completed: " + cfg.SystemPrompt, + ForUser: "completed", + }, nil +} + +func TestDelegateTool_Name(t *testing.T) { + tool := NewDelegateTool() + if tool.Name() != "delegate" { + t.Errorf("Name() = %q, want %q", tool.Name(), "delegate") + } +} + +func TestDelegateTool_Parameters(t *testing.T) { + tool := NewDelegateTool() + params := tool.Parameters() + + props, ok := params["properties"].(map[string]any) + if !ok { + t.Fatal("properties should be a map") + } + _, hasAgentID := props["agent_id"] + if !hasAgentID { + t.Error("agent_id parameter should exist") + } + _, hasTask := props["task"] + if !hasTask { + t.Error("task parameter should exist") + } + + required, ok := params["required"].([]string) + if !ok { + t.Fatal("required should be a string array") + } + if len(required) != 2 { + t.Fatalf("required should have 2 entries, got %d", len(required)) + } +} + +func TestDelegateTool_Execute_Success(t *testing.T) { + spawner := &delegateMockSpawner{} + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "summarize the logs", + }) + + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, `[Response from agent "researcher"]`) { + t.Errorf("result should contain attribution, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "summarize the logs") { + t.Errorf("result should contain task output, got: %s", result.ForLLM) + } + + // Verify spawner received correct config + if spawner.lastCfg.TargetAgentID != "researcher" { + t.Errorf("TargetAgentID = %q, want %q", spawner.lastCfg.TargetAgentID, "researcher") + } + if spawner.lastCfg.Async { + t.Error("delegate should be synchronous (Async=false)") + } + if spawner.lastCfg.SystemPrompt != "summarize the logs" { + t.Errorf("SystemPrompt = %q, want %q", spawner.lastCfg.SystemPrompt, "summarize the logs") + } +} + +func TestDelegateTool_Execute_EmptyAgentID(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"task": "test"}}, + {"empty string", map[string]any{"agent_id": "", "task": "test"}}, + {"whitespace only", map[string]any{"agent_id": " ", "task": "test"}}, + {"wrong type", map[string]any{"agent_id": 123, "task": "test"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid agent_id") + } + if !strings.Contains(result.ForLLM, "agent_id is required") { + t.Errorf("error should mention agent_id, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_EmptyTask(t *testing.T) { + tests := []struct { + name string + args map[string]any + }{ + {"missing", map[string]any{"agent_id": "a"}}, + {"empty string", map[string]any{"agent_id": "a", "task": ""}}, + {"whitespace only", map[string]any{"agent_id": "a", "task": "\t\n"}}, + } + + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tool.Execute(context.Background(), tt.args) + if !result.IsError { + t.Error("expected error for invalid task") + } + if !strings.Contains(result.ForLLM, "task is required") { + t.Errorf("error should mention task, got: %s", result.ForLLM) + } + }) + } +} + +func TestDelegateTool_Execute_PermissionDenied(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "forbidden-agent", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for denied agent") + } + if !strings.Contains(result.ForLLM, "not allowed to delegate") { + t.Errorf("error should mention permission, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_PermissionAllowed(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetAllowlistChecker(func(targetAgentID string) bool { + return targetAgentID == "allowed-agent" + }) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "allowed-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success for allowed agent, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoSpawner(t *testing.T) { + tool := NewDelegateTool() + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "a", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner is nil") + } + if !strings.Contains(result.ForLLM, "not configured") { + t.Errorf("error should mention not configured, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SpawnerError(t *testing.T) { + spawner := &delegateMockSpawner{ + err: fmt.Errorf("context deadline exceeded"), + } + tool := NewDelegateTool() + tool.SetSpawner(spawner) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error when spawner fails") + } + if !strings.Contains(result.ForLLM, "delegation to agent") { + t.Errorf("error should mention delegation failure, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "context deadline exceeded") { + t.Errorf("error should propagate cause, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NoAllowlistCheck(t *testing.T) { + // When no allowlist checker is set, all agents are allowed + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "any-agent", + "task": "test", + }) + + if result.IsError { + t.Errorf("expected success without allowlist, got error: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_NilResult(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&nilResultSpawner{}) + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "researcher", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for nil result") + } + if !strings.Contains(result.ForLLM, "returned no result") { + t.Errorf("error should mention no result, got: %s", result.ForLLM) + } +} + +func TestDelegateTool_Execute_SelfDelegation(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") + + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": "alpha", + "task": "test", + }) + + if !result.IsError { + t.Error("expected error for self-delegation") + } + if !strings.Contains(result.ForLLM, "cannot delegate to self") { + t.Errorf("error should mention self-delegation, got: %s", result.ForLLM) + } +} + +// nilResultSpawner always returns (nil, nil). +type nilResultSpawner struct{} + +func (m *nilResultSpawner) SpawnSubTurn(_ context.Context, _ SubTurnConfig) (*ToolResult, error) { + return nil, nil +} From 039f35563e6222da0acac3a1dada2f27e6174bec Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 21:29:29 +0800 Subject: [PATCH 16/71] feat(agent): wire delegate tool registration for multi-agent setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the delegate tool in registerSharedTools when multiple agents are configured. Gated independently from the subagent tool — delegate uses SubTurn directly and does not depend on SubagentManager. Self-delegation is prevented by injecting the current agent ID. Permission is enforced via CanSpawnSubagent (reuses allow_agents config). Single-agent setups are unaffected: the tool is not registered when only one agent exists in the registry. Ref: #2148 --- pkg/agent/loop.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index a856c0fca..d31d2af45 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -440,6 +440,20 @@ func registerSharedTools( } else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") { logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil) } + + // Register delegate tool for multi-agent setups. + // Delegation uses the SubTurn mechanism directly (not SubagentManager), + // so it does not depend on the subagent tool being enabled. + if cfg.Tools.IsToolEnabled("delegate") && len(registry.ListAgentIDs()) > 1 { + delegateTool := tools.NewDelegateTool() + delegateTool.SetSpawner(NewSubTurnSpawner(al)) + currentAgentID := agentID + delegateTool.SetSelfAgentID(currentAgentID) + delegateTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) + }) + agent.Tools.Register(delegateTool) + } } } From df486b99393cf9e69550b2f4406938977a5f7b2b Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 22:23:17 +0800 Subject: [PATCH 17/71] fix(tools): normalize agent_id before self-check and delegation Apply routing.NormalizeAgentID to the raw agent_id input before any logic runs. This prevents case/whitespace variants like "ALPHA" or " alpha " from bypassing the self-delegation guard while still resolving to the same agent in the registry. The normalized value is used consistently for self-check, allowlist, SpawnSubTurn, and result attribution. Ref: #2148 --- pkg/tools/delegate.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/tools/delegate.go b/pkg/tools/delegate.go index 8831ffeb3..dcde27718 100644 --- a/pkg/tools/delegate.go +++ b/pkg/tools/delegate.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "strings" + + "github.com/sipeed/picoclaw/pkg/routing" ) // DelegateTool delegates a task to a specific named agent and waits for @@ -61,10 +63,11 @@ func (t *DelegateTool) Parameters() map[string]any { } func (t *DelegateTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - agentID, _ := args["agent_id"].(string) - if strings.TrimSpace(agentID) == "" { + rawAgentID, _ := args["agent_id"].(string) + if strings.TrimSpace(rawAgentID) == "" { return ErrorResult("agent_id is required and must be a non-empty string") } + agentID := routing.NormalizeAgentID(rawAgentID) task, _ := args["task"].(string) if strings.TrimSpace(task) == "" { From 6db17b8211a99c070294437e8fca03a4ddcd0269 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 22:23:47 +0800 Subject: [PATCH 18/71] test(tools): verify normalization prevents self-delegation bypass Add table-driven test with case and whitespace variants (ALPHA, " Alpha ", " alpha ") that should all be caught by the self-check after normalization. Ref: #2148 --- pkg/tools/delegate_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/tools/delegate_test.go b/pkg/tools/delegate_test.go index f1b4c456f..729c524a7 100644 --- a/pkg/tools/delegate_test.go +++ b/pkg/tools/delegate_test.go @@ -272,6 +272,26 @@ func TestDelegateTool_Execute_SelfDelegation(t *testing.T) { } } +func TestDelegateTool_Execute_SelfDelegation_Normalized(t *testing.T) { + tool := NewDelegateTool() + tool.SetSpawner(&delegateMockSpawner{}) + tool.SetSelfAgentID("alpha") // stored normalized + + // Case-insensitive and whitespace variants should still be caught + variants := []string{"ALPHA", " Alpha ", " alpha "} + for _, v := range variants { + t.Run(v, func(t *testing.T) { + result := tool.Execute(context.Background(), map[string]any{ + "agent_id": v, + "task": "test", + }) + if !result.IsError { + t.Errorf("agent_id=%q should be caught as self-delegation", v) + } + }) + } +} + // nilResultSpawner always returns (nil, nil). type nilResultSpawner struct{} From 6ee66123f22f96eb273fb4b247855bbe1d7ade89 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 22:24:47 +0800 Subject: [PATCH 19/71] refactor(agent): simplify delegate registration gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the IsToolEnabled("delegate") check — there is no "delegate" entry in ToolsConfig, so the check was always true. The only real gate is len(agents) > 1, which is the intended behavior: delegate is auto-registered in multi-agent setups. Ref: #2148 --- pkg/agent/loop.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d31d2af45..c48c1041b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -442,9 +442,10 @@ func registerSharedTools( } // Register delegate tool for multi-agent setups. - // Delegation uses the SubTurn mechanism directly (not SubagentManager), - // so it does not depend on the subagent tool being enabled. - if cfg.Tools.IsToolEnabled("delegate") && len(registry.ListAgentIDs()) > 1 { + // Auto-enabled when multiple agents exist. Delegation uses the SubTurn + // mechanism directly (not SubagentManager) and is independent of the + // subagent tool. + if len(registry.ListAgentIDs()) > 1 { delegateTool := tools.NewDelegateTool() delegateTool.SetSpawner(NewSubTurnSpawner(al)) currentAgentID := agentID From a34120b8219eb342846c538b5ddb4c0e59a095c7 Mon Sep 17 00:00:00 2001 From: xiaoen <2768753269@qq.com> Date: Wed, 15 Apr 2026 22:27:05 +0800 Subject: [PATCH 20/71] test(agent): assert child turn uses target agent model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace generic mockProvider with modelRecordingProvider that captures the model parameter passed to Chat(). After delegation from alpha to beta, assert the recorded model is "model-beta" — proving the child turn actually ran with the target agent's configuration, not the caller's. Also add wiring tests: - TestDelegateToolNotRegistered_SingleAgent: single-agent has no delegate in its tool registry - TestDelegateToolRegistered_MultiAgent: both agents in a two-agent setup have the delegate tool Ref: #2148 --- pkg/agent/subturn_test.go | 80 +++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index b3015149e..c28d8c045 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -2071,9 +2071,36 @@ func TestSubTurn_IndependentContext(t *testing.T) { // ====================== TargetAgentID Tests ====================== +// modelRecordingProvider captures the model passed to Chat for test assertions. +type modelRecordingProvider struct { + mu sync.Mutex + lastModel string +} + +func (rp *modelRecordingProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + model string, + _ map[string]any, +) (*providers.LLMResponse, error) { + rp.mu.Lock() + rp.lastModel = model + rp.mu.Unlock() + return &providers.LLMResponse{Content: "Mock response"}, nil +} + +func (rp *modelRecordingProvider) GetDefaultModel() string { return "mock-model" } + +func (rp *modelRecordingProvider) getLastModel() string { + rp.mu.Lock() + defer rp.mu.Unlock() + return rp.lastModel +} + // newMultiAgentLoop creates an AgentLoop with two named agents for testing // cross-agent delegation via TargetAgentID. -func newMultiAgentLoop(t *testing.T) (*AgentLoop, func()) { +func newMultiAgentLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, func()) { t.Helper() tmpDir, err := os.MkdirTemp("", "multiagent-test-*") if err != nil { @@ -2109,24 +2136,20 @@ func newMultiAgentLoop(t *testing.T) (*AgentLoop, func()) { } msgBus := bus.NewMessageBus() - provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) return al, func() { os.RemoveAll(tmpDir) } } func TestSpawnSubTurn_TargetAgentID_UsesTargetAgent(t *testing.T) { - al, cleanup := newMultiAgentLoop(t) + rp := &modelRecordingProvider{} + al, cleanup := newMultiAgentLoop(t, rp) defer cleanup() alphaAgent, ok := al.registry.GetAgent("alpha") if !ok { t.Fatal("alpha agent not in registry") } - betaAgent, ok := al.registry.GetAgent("beta") - if !ok { - t.Fatal("beta agent not in registry") - } // Parent is alpha, target is beta parent := &turnState{ @@ -2151,14 +2174,16 @@ func TestSpawnSubTurn_TargetAgentID_UsesTargetAgent(t *testing.T) { t.Fatal("expected non-nil result") } - // Verify the two agents have distinct models (test setup sanity check) - if alphaAgent.Model == betaAgent.Model { - t.Fatal("test setup error: alpha and beta should have different models") + // The recording provider captures the model passed to Chat(). + // If TargetAgentID works correctly, the child turn should have + // used beta's model, not alpha's. + if got := rp.getLastModel(); got != "model-beta" { + t.Errorf("child turn used model %q, want %q", got, "model-beta") } } func TestSpawnSubTurn_TargetAgentID_NotFound(t *testing.T) { - al, cleanup := newMultiAgentLoop(t) + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) defer cleanup() alphaAgent, _ := al.registry.GetAgent("alpha") @@ -2187,7 +2212,7 @@ func TestSpawnSubTurn_TargetAgentID_NotFound(t *testing.T) { } func TestSpawnSubTurn_TargetAgentID_EmptyModelAccepted(t *testing.T) { - al, cleanup := newMultiAgentLoop(t) + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) defer cleanup() alphaAgent, _ := al.registry.GetAgent("alpha") @@ -2215,3 +2240,34 @@ func TestSpawnSubTurn_TargetAgentID_EmptyModelAccepted(t *testing.T) { t.Fatal("expected non-nil result") } } + +func TestDelegateToolNotRegistered_SingleAgent(t *testing.T) { + // Single-agent setup: delegate should not be registered + al, _, _, provider, cleanup := newTestAgentLoop(t) + _ = provider + defer cleanup() + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("default agent should exist") + } + if _, has := agent.Tools.Get("delegate"); has { + t.Error("delegate tool should not be registered in single-agent setup") + } +} + +func TestDelegateToolRegistered_MultiAgent(t *testing.T) { + al, cleanup := newMultiAgentLoop(t, &mockProvider{}) + defer cleanup() + + // Both agents should have the delegate tool + for _, id := range []string{"alpha", "beta"} { + agent, ok := al.registry.GetAgent(id) + if !ok { + t.Fatalf("agent %q not found", id) + } + if _, has := agent.Tools.Get("delegate"); !has { + t.Errorf("agent %q should have delegate tool in multi-agent setup", id) + } + } +} From 5c0492900e885e446124a7a50e01257cc40ea646 Mon Sep 17 00:00:00 2001 From: hehaijunandhenry Date: Wed, 29 Apr 2026 11:18:16 +0800 Subject: [PATCH 21/71] add MQTT channel support --- README.md | 5 +- docs/channels/mqtt/README.fr.md | 140 ++++++++++ docs/channels/mqtt/README.ja.md | 140 ++++++++++ docs/channels/mqtt/README.md | 142 ++++++++++ docs/channels/mqtt/README.pt-br.md | 140 ++++++++++ docs/channels/mqtt/README.vi.md | 140 ++++++++++ docs/channels/mqtt/README.zh.md | 142 ++++++++++ docs/guides/chat-apps.fr.md | 67 ++++- docs/guides/chat-apps.ja.md | 65 +++++ docs/guides/chat-apps.md | 69 ++++- docs/guides/chat-apps.ms.md | 67 ++++- docs/guides/chat-apps.pt-br.md | 67 ++++- docs/guides/chat-apps.vi.md | 67 ++++- docs/guides/chat-apps.zh.md | 69 ++++- docs/security/security_configuration.md | 20 ++ go.mod | 1 + go.sum | 2 + pkg/channels/README.md | 1 + pkg/channels/README.zh.md | 1 + pkg/channels/manager.go | 2 + pkg/channels/mqtt/init.go | 16 ++ pkg/channels/mqtt/mqtt.go | 242 +++++++++++++++++ pkg/config/config.go | 11 + pkg/config/config_channel.go | 2 + pkg/gateway/gateway.go | 1 + web/README.md | 2 +- web/backend/api/channels.go | 2 + .../channels/channel-config-fields.ts | 2 + .../channels/channel-config-page.tsx | 15 ++ .../channels/channel-forms/mqtt-form.tsx | 248 ++++++++++++++++++ web/frontend/src/i18n/locales/en.json | 29 +- web/frontend/src/i18n/locales/zh.json | 29 +- 32 files changed, 1933 insertions(+), 13 deletions(-) create mode 100644 docs/channels/mqtt/README.fr.md create mode 100644 docs/channels/mqtt/README.ja.md create mode 100644 docs/channels/mqtt/README.md create mode 100644 docs/channels/mqtt/README.pt-br.md create mode 100644 docs/channels/mqtt/README.vi.md create mode 100644 docs/channels/mqtt/README.zh.md create mode 100644 pkg/channels/mqtt/init.go create mode 100644 pkg/channels/mqtt/mqtt.go create mode 100644 web/frontend/src/components/channels/channel-forms/mqtt-form.tsx diff --git a/README.md b/README.md index 30ac67d8f..6e5dc66e0 100644 --- a/README.md +++ b/README.md @@ -447,7 +447,7 @@ For full provider configuration details, see [Providers & Models](docs/guides/pr ## 💬 Channels (Chat Apps) -Talk to your PicoClaw through 18+ messaging platforms: +Talk to your PicoClaw through 19+ messaging platforms: | Channel | Setup | Protocol | Docs | |---------|-------|----------|------| @@ -465,6 +465,7 @@ Talk to your PicoClaw through 18+ messaging platforms: | **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) | | **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) | | **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) | +| **MQTT** | Easy (broker + agent_id) | MQTT pub/sub | [Guide](docs/channels/mqtt/README.md) | | **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) | | **Pico** | Easy (enable) | Native protocol | Built-in | | **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in | @@ -617,7 +618,7 @@ For detailed guides beyond this README: | Topic | Description | |-------|-------------| | [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes | -| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides | +| [Chat Apps](docs/guides/chat-apps.md) | All 18+ 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 | diff --git a/docs/channels/mqtt/README.fr.md b/docs/channels/mqtt/README.fr.md new file mode 100644 index 000000000..c16868a32 --- /dev/null +++ b/docs/channels/mqtt/README.fr.md @@ -0,0 +1,140 @@ +# 📡 Canal MQTT + +PicoClaw prend en charge n'importe quel client MQTT comme canal de messagerie. Les appareils ou services publient des requêtes vers un broker ; PicoClaw s'abonne, les traite et publie les réponses en retour. + +## 🚀 Démarrage rapide + +**1. Ajouter le canal dans `~/.picoclaw/config.json` :** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "tcp://localhost:1883", + "agent_id": "assistant" + } + } + } +} +``` + +**2. Démarrer la passerelle :** + +```bash +picoclaw gateway +``` + +**3. Envoyer un message depuis n'importe quel client MQTT :** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "Quel est l'\''usage CPU ?"}' +``` + +**4. S'abonner pour recevoir la réponse :** + +```bash +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +--- + +## 📨 Structure des topics + +``` +{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client +``` + +| Segment | Description | +|---------|-------------| +| `prefix` | Préfixe de topic configuré côté serveur. Défaut : `/picoclaw` | +| `agent_id` | Identifiant de l'instance PicoClaw, défini dans le champ `agent_id` | +| `client_id` | Identifiant de session défini par le client — utiliser un ID stable par appareil pour maintenir le contexte | + +### Payload du message (JSON) + +```json +{ "text": "votre message ici" } +``` + +--- + +## ⚙️ Configuration + +### config.json + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://votre-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "client_id": "", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +### .security.yml (identifiants) + +Le nom d'utilisateur et le mot de passe sont stockés dans `~/.picoclaw/.security.yml`, pas dans `config.json` : + +```yaml +channel_list: + mqtt: + settings: + username: votre_utilisateur + password: votre_mot_de_passe +``` + +### Champs de configuration + +| Champ | Emplacement | Requis | Défaut | Description | +|-------|-------------|--------|--------|-------------| +| `broker` | `settings` | Oui | — | URL du broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` | +| `agent_id` | `settings` | Oui | — | Identifiant de l'agent, utilisé dans le chemin du topic | +| `topic_prefix` | `settings` | Non | `/picoclaw` | Préfixe de l'espace de noms des topics | +| `username` | `.security.yml` | Non | — | Nom d'utilisateur pour l'authentification au broker | +| `password` | `.security.yml` | Non | — | Mot de passe pour l'authentification au broker | +| `client_id` | `settings` | Non | auto-généré | ID client paho envoyé au broker. Auto-généré sous la forme `picoclaw-mqtt-{agent_id}-{8 hex}` ; fixe pour la durée du processus, réutilisé à la reconnexion | +| `keep_alive` | `settings` | Non | `60` | Intervalle keepalive MQTT en secondes | +| `qos` | `settings` | Non | `0` | Niveau QoS pour la publication et l'abonnement : `0`, `1` ou `2` | + +### Variables d'environnement + +| Variable | Champ | +|----------|-------| +| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` | +| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` | +| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` | +| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` | +| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` | +| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` | +| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` | +| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` | + +--- + +## 🔄 Reconnexion + +PicoClaw se reconnecte automatiquement au broker en cas de perte de connexion, avec un intervalle de 5 secondes. L'abonnement est rétabli automatiquement. L'ID client côté broker reste identique à chaque reconnexion. + +--- + +## ⚠️ Remarques + +- **TLS** : SSL/TLS est supporté (URL broker en `ssl://`). La vérification du certificat est désactivée par défaut. +- **Réponses en streaming** : Les réponses en streaming envoient plusieurs messages vers le topic de réponse ; les concaténer dans l'ordre pour obtenir la réponse complète. +- **client_id vs ID de session** : Le `client_id` dans le chemin du topic est défini par votre application cliente. Il est distinct de l'ID client paho utilisé par PicoClaw pour se connecter au broker. +- **Instances multiples** : Si plusieurs instances PicoClaw utilisent le même `agent_id` sur le même broker, définir des `client_id` distincts pour éviter les conflits. diff --git a/docs/channels/mqtt/README.ja.md b/docs/channels/mqtt/README.ja.md new file mode 100644 index 000000000..80ccafdc5 --- /dev/null +++ b/docs/channels/mqtt/README.ja.md @@ -0,0 +1,140 @@ +# 📡 MQTT チャンネル + +PicoClaw は任意の MQTT クライアントをメッセージチャンネルとして使用できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。 + +## 🚀 クイックスタート + +**1. `~/.picoclaw/config.json` にチャンネルを追加:** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "tcp://localhost:1883", + "agent_id": "assistant" + } + } + } +} +``` + +**2. ゲートウェイを起動:** + +```bash +picoclaw gateway +``` + +**3. 任意の MQTT クライアントからメッセージを送信:** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "CPU使用率を確認してください"}' +``` + +**4. レスポンスを受信するためにサブスクライブ:** + +```bash +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +--- + +## 📨 トピック構造 + +``` +{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント +``` + +| セグメント | 説明 | +|-----------|------| +| `prefix` | トピックのプレフィックス。サーバー側で設定。デフォルト:`/picoclaw` | +| `agent_id` | PicoClaw インスタンスの識別子。`agent_id` フィールドに設定 | +| `client_id` | クライアントが定義するセッション識別子。デバイスごとに同一の ID を使用するとコンテキストが維持される | + +### メッセージペイロード(JSON) + +```json +{ "text": "メッセージ内容" } +``` + +--- + +## ⚙️ 設定 + +### config.json + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "client_id": "", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +### .security.yml(認証情報) + +ユーザー名とパスワードは `config.json` ではなく `~/.picoclaw/.security.yml` に保存します: + +```yaml +channel_list: + mqtt: + settings: + username: your_username + password: your_password +``` + +### 設定フィールド + +| フィールド | 場所 | 必須 | デフォルト | 説明 | +|-----------|------|------|-----------|------| +| `broker` | `settings` | はい | — | MQTT ブローカー URL。例:`tcp://host:1883`、`ssl://host:8883` | +| `agent_id` | `settings` | はい | — | エージェント識別子。トピックパスの一部として使用される | +| `topic_prefix` | `settings` | いいえ | `/picoclaw` | トピックの名前空間プレフィックス | +| `username` | `.security.yml` | いいえ | — | ブローカー認証のユーザー名 | +| `password` | `.security.yml` | いいえ | — | ブローカー認証のパスワード | +| `client_id` | `settings` | いいえ | 自動生成 | ブローカーに送信する paho クライアント ID。未設定の場合 `picoclaw-mqtt-{agent_id}-{8桁hex}` で自動生成。プロセスの生存期間中は固定され、再接続時も同じ ID を使用 | +| `keep_alive` | `settings` | いいえ | `60` | MQTT キープアライブ間隔(秒) | +| `qos` | `settings` | いいえ | `0` | パブリッシュおよびサブスクライブの QoS レベル:`0`、`1`、`2` | + +### 環境変数 + +| 変数 | フィールド | +|------|----------| +| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` | +| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` | +| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` | +| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` | +| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` | +| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` | +| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` | +| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` | + +--- + +## 🔄 再接続 + +接続が切断された場合、PicoClaw は 5 秒間隔で自動的にブローカーに再接続します。再接続後はサブスクリプションも自動的に再確立されます。再接続時はブローカー側のクライアント ID が同一に保たれるため、ブローカーは同じセッションとして認識します。 + +--- + +## ⚠️ 注意事項 + +- **TLS**:SSL/TLS をサポートしています(ブローカー URL に `ssl://` を使用)。デフォルトでは証明書検証をスキップします。 +- **ストリーミングレスポンス**:ストリーミング出力時はレスポンストピックに複数のメッセージが送信されます。順番に結合すると完全なレスポンスになります。 +- **client_id とセッション ID の違い**:トピックパスの `client_id` はクライアントアプリケーションが設定するセッション識別子です。PicoClaw がブローカーへの接続に使用する paho クライアント ID とは別の概念です。 +- **複数インスタンス**:同じ `agent_id` で複数の PicoClaw インスタンスを同一ブローカーに接続する場合、ブローカーレベルの競合を避けるために各インスタンスに異なる `client_id` を設定してください。 diff --git a/docs/channels/mqtt/README.md b/docs/channels/mqtt/README.md new file mode 100644 index 000000000..c894d77f7 --- /dev/null +++ b/docs/channels/mqtt/README.md @@ -0,0 +1,142 @@ +# 📡 MQTT Channel + +PicoClaw supports any MQTT client as a chat channel. Devices or services publish requests to a broker; PicoClaw subscribes, processes them, and publishes responses back. + +## 🚀 Quick Start + +**1. Add the channel to `~/.picoclaw/config.json`:** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "tcp://localhost:1883", + "agent_id": "assistant" + } + } + } +} +``` + +**2. Start the gateway:** + +```bash +picoclaw gateway +``` + +**3. Send a message from any MQTT client:** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "What is the CPU usage?"}' +``` + +**4. Subscribe to receive the response:** + +```bash +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +--- + +## 📨 Topic Structure + +``` +{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client +``` + +| Segment | Description | +|---------|-------------| +| `prefix` | Topic prefix, configured server-side. Default: `/picoclaw` | +| `agent_id` | PicoClaw instance identifier, set in `agent_id` config field | +| `client_id` | Client-defined session identifier — use a stable ID per device to maintain conversation context | + +### Message Payload (JSON) + +```json +{ "text": "your message here" } +``` + +--- + +## ⚙️ Configuration + +### config.json + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "client_id": "", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +### .security.yml (credentials) + +Username and password are stored in `~/.picoclaw/.security.yml`, not in `config.json`: + +```yaml +channel_list: + mqtt: + settings: + username: your_username + password: your_password +``` + +### Configuration Fields + +| Field | Location | Required | Default | Description | +|-------|----------|----------|---------|-------------| +| `broker` | `settings` | Yes | — | MQTT broker URL, e.g. `tcp://host:1883`, `ssl://host:8883` | +| `agent_id` | `settings` | Yes | — | Agent identifier, used as part of the topic path | +| `topic_prefix` | `settings` | No | `/picoclaw` | Topic namespace prefix | +| `username` | `.security.yml` | No | — | Broker authentication username | +| `password` | `.security.yml` | No | — | Broker authentication password | +| `client_id` | `settings` | No | auto-generated | Paho client ID sent to the broker. Auto-generated as `picoclaw-mqtt-{agent_id}-{8-char hex}` if not set; stays fixed for the process lifetime so reconnects reuse the same ID | +| `keep_alive` | `settings` | No | `60` | MQTT keepalive interval in seconds | +| `qos` | `settings` | No | `0` | QoS level for publish and subscribe: `0`, `1`, or `2` | + +### Environment Variables + +All fields can be set via environment variables: + +| Variable | Field | +|----------|-------| +| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` | +| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` | +| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` | +| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` | +| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` | +| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` | +| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` | +| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` | + +--- + +## 🔄 Reconnection + +PicoClaw automatically reconnects to the broker if the connection is lost, with a 5-second retry interval. On reconnect, the subscription is re-established automatically. The broker-side client ID stays the same across reconnects so the broker correctly identifies it as the same session. + +--- + +## ⚠️ Notes + +- **TLS**: SSL/TLS is supported (`ssl://` broker URL). Certificate verification is skipped by default. +- **Streaming**: Streaming responses send multiple messages to the response topic; concatenate them in order. +- **client_id vs session ID**: The `client_id` in the topic path is set by your client application and identifies the conversation session. It is separate from the broker-level client ID used by PicoClaw's paho connection. +- **Multiple instances**: If you run multiple PicoClaw instances against the same broker with the same `agent_id`, set distinct `client_id` values to avoid broker-level conflicts. diff --git a/docs/channels/mqtt/README.pt-br.md b/docs/channels/mqtt/README.pt-br.md new file mode 100644 index 000000000..da95b6ba6 --- /dev/null +++ b/docs/channels/mqtt/README.pt-br.md @@ -0,0 +1,140 @@ +# 📡 Canal MQTT + +O PicoClaw suporta qualquer cliente MQTT como canal de mensagens. Dispositivos ou serviços publicam requisições para um broker; o PicoClaw assina, processa e publica as respostas de volta. + +## 🚀 Início rápido + +**1. Adicione o canal ao `~/.picoclaw/config.json`:** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "tcp://localhost:1883", + "agent_id": "assistant" + } + } + } +} +``` + +**2. Inicie o gateway:** + +```bash +picoclaw gateway +``` + +**3. Envie uma mensagem de qualquer cliente MQTT:** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "Qual é o uso de CPU?"}' +``` + +**4. Assine para receber a resposta:** + +```bash +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +--- + +## 📨 Estrutura de tópicos + +``` +{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente +``` + +| Segmento | Descrição | +|----------|-----------| +| `prefix` | Prefixo do tópico configurado no servidor. Padrão: `/picoclaw` | +| `agent_id` | Identificador da instância do PicoClaw, definido no campo `agent_id` | +| `client_id` | Identificador de sessão definido pelo cliente — use um ID estável por dispositivo para manter o contexto da conversa | + +### Payload da mensagem (JSON) + +```json +{ "text": "sua mensagem aqui" } +``` + +--- + +## ⚙️ Configuração + +### config.json + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://seu-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "client_id": "", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +### .security.yml (credenciais) + +O nome de usuário e a senha são armazenados em `~/.picoclaw/.security.yml`, não no `config.json`: + +```yaml +channel_list: + mqtt: + settings: + username: seu_usuario + password: sua_senha +``` + +### Campos de configuração + +| Campo | Local | Obrigatório | Padrão | Descrição | +|-------|-------|-------------|--------|-----------| +| `broker` | `settings` | Sim | — | URL do broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` | +| `agent_id` | `settings` | Sim | — | Identificador do agente, usado como parte do caminho do tópico | +| `topic_prefix` | `settings` | Não | `/picoclaw` | Prefixo do namespace dos tópicos | +| `username` | `.security.yml` | Não | — | Nome de usuário para autenticação no broker | +| `password` | `.security.yml` | Não | — | Senha para autenticação no broker | +| `client_id` | `settings` | Não | gerado automaticamente | ID de cliente paho enviado ao broker. Gerado automaticamente como `picoclaw-mqtt-{agent_id}-{8 hex}` se não definido; fixo durante o tempo de vida do processo e reutilizado nas reconexões | +| `keep_alive` | `settings` | Não | `60` | Intervalo de keepalive MQTT em segundos | +| `qos` | `settings` | Não | `0` | Nível de QoS para publicação e assinatura: `0`, `1` ou `2` | + +### Variáveis de ambiente + +| Variável | Campo | +|----------|-------| +| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` | +| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` | +| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` | +| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` | +| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` | +| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` | +| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` | +| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` | + +--- + +## 🔄 Reconexão + +O PicoClaw reconecta automaticamente ao broker se a conexão for perdida, com intervalo de 5 segundos. Após a reconexão, a assinatura é restabelecida automaticamente. O ID de cliente no broker permanece o mesmo nas reconexões, permitindo que o broker identifique corretamente a mesma sessão. + +--- + +## ⚠️ Observações + +- **TLS**: SSL/TLS é suportado (URL do broker com `ssl://`). A verificação de certificado é ignorada por padrão. +- **Respostas em streaming**: Respostas em streaming enviam múltiplas mensagens para o tópico de resposta; concatene-as na ordem recebida para obter a resposta completa. +- **client_id vs ID de sessão**: O `client_id` no caminho do tópico é definido pela sua aplicação cliente e identifica a sessão. É separado do ID de cliente paho usado pelo PicoClaw para se conectar ao broker. +- **Múltiplas instâncias**: Se várias instâncias do PicoClaw usarem o mesmo `agent_id` no mesmo broker, defina `client_id` distintos para evitar conflitos no nível do broker. diff --git a/docs/channels/mqtt/README.vi.md b/docs/channels/mqtt/README.vi.md new file mode 100644 index 000000000..f680c78bb --- /dev/null +++ b/docs/channels/mqtt/README.vi.md @@ -0,0 +1,140 @@ +# 📡 Kênh MQTT + +PicoClaw hỗ trợ bất kỳ client MQTT nào làm kênh nhắn tin. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại. + +## 🚀 Bắt đầu nhanh + +**1. Thêm kênh vào `~/.picoclaw/config.json`:** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "tcp://localhost:1883", + "agent_id": "assistant" + } + } + } +} +``` + +**2. Khởi động gateway:** + +```bash +picoclaw gateway +``` + +**3. Gửi tin nhắn từ bất kỳ client MQTT nào:** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "CPU đang dùng bao nhiêu phần trăm?"}' +``` + +**4. Subscribe để nhận phản hồi:** + +```bash +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +--- + +## 📨 Cấu trúc topic + +``` +{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client +``` + +| Phân đoạn | Mô tả | +|-----------|-------| +| `prefix` | Tiền tố topic, cấu hình phía server. Mặc định: `/picoclaw` | +| `agent_id` | Định danh instance PicoClaw, đặt trong trường `agent_id` | +| `client_id` | Định danh phiên do client xác định — dùng ID ổn định cho mỗi thiết bị để duy trì ngữ cảnh hội thoại | + +### Payload tin nhắn (JSON) + +```json +{ "text": "nội dung tin nhắn" } +``` + +--- + +## ⚙️ Cấu hình + +### config.json + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "client_id": "", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +### .security.yml (thông tin xác thực) + +Tên người dùng và mật khẩu được lưu trong `~/.picoclaw/.security.yml`, không phải trong `config.json`: + +```yaml +channel_list: + mqtt: + settings: + username: ten_nguoi_dung + password: mat_khau +``` + +### Các trường cấu hình + +| Trường | Vị trí | Bắt buộc | Mặc định | Mô tả | +|--------|--------|----------|----------|-------| +| `broker` | `settings` | Có | — | URL của MQTT broker, ví dụ `tcp://host:1883`, `ssl://host:8883` | +| `agent_id` | `settings` | Có | — | Định danh agent, dùng làm một phần của đường dẫn topic | +| `topic_prefix` | `settings` | Không | `/picoclaw` | Tiền tố không gian tên topic | +| `username` | `.security.yml` | Không | — | Tên người dùng xác thực với broker | +| `password` | `.security.yml` | Không | — | Mật khẩu xác thực với broker | +| `client_id` | `settings` | Không | tự động tạo | Client ID paho gửi đến broker. Tự động tạo dạng `picoclaw-mqtt-{agent_id}-{8 hex}` nếu không đặt; cố định trong suốt vòng đời tiến trình, tái sử dụng khi kết nối lại | +| `keep_alive` | `settings` | Không | `60` | Khoảng thời gian keepalive MQTT (giây) | +| `qos` | `settings` | Không | `0` | Mức QoS cho publish và subscribe: `0`, `1` hoặc `2` | + +### Biến môi trường + +| Biến | Trường | +|------|--------| +| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` | +| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` | +| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` | +| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` | +| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` | +| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` | +| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` | +| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` | + +--- + +## 🔄 Kết nối lại + +PicoClaw tự động kết nối lại với broker nếu mất kết nối, với khoảng thời gian thử lại 5 giây. Sau khi kết nối lại, subscription được tái thiết lập tự động. Client ID phía broker giữ nguyên qua các lần kết nối lại, giúp broker nhận diện chính xác cùng một phiên. + +--- + +## ⚠️ Lưu ý + +- **TLS**: Hỗ trợ SSL/TLS (URL broker dùng `ssl://`). Mặc định bỏ qua xác minh chứng chỉ. +- **Phản hồi streaming**: Phản hồi streaming gửi nhiều tin nhắn đến topic response; ghép nối chúng theo thứ tự để có phản hồi đầy đủ. +- **client_id và ID phiên**: `client_id` trong đường dẫn topic được đặt bởi ứng dụng client của bạn và xác định phiên hội thoại. Nó khác với client ID paho mà PicoClaw dùng để kết nối broker. +- **Nhiều instance**: Nếu nhiều instance PicoClaw dùng cùng `agent_id` trên cùng broker, hãy đặt `client_id` riêng biệt cho từng instance để tránh xung đột ở tầng broker. diff --git a/docs/channels/mqtt/README.zh.md b/docs/channels/mqtt/README.zh.md new file mode 100644 index 000000000..e7e529cde --- /dev/null +++ b/docs/channels/mqtt/README.zh.md @@ -0,0 +1,142 @@ +# 📡 MQTT 渠道 + +PicoClaw 支持将任意 MQTT 客户端作为消息渠道。设备或服务向 Broker 发布请求,PicoClaw 订阅后处理并将响应发布回去。 + +## 🚀 快速开始 + +**1. 在 `~/.picoclaw/config.json` 中添加渠道:** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "tcp://localhost:1883", + "agent_id": "assistant" + } + } + } +} +``` + +**2. 启动网关:** + +```bash +picoclaw gateway +``` + +**3. 用任意 MQTT 客户端发送消息:** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "查一下CPU使用率"}' +``` + +**4. 订阅响应:** + +```bash +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +--- + +## 📨 Topic 结构 + +``` +{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端 +``` + +| 段 | 说明 | +|----|------| +| `prefix` | Topic 前缀,由服务端配置,默认 `/picoclaw` | +| `agent_id` | PicoClaw 实例标识,对应配置中的 `agent_id` 字段 | +| `client_id` | 客户端自定义会话标识——同一设备保持相同 ID 可维持上下文连续性 | + +### 消息体(JSON) + +```json +{ "text": "你的消息内容" } +``` + +--- + +## ⚙️ 配置说明 + +### config.json + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "client_id": "", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +### .security.yml(用户名和密码) + +用户名和密码存储于 `~/.picoclaw/.security.yml`,不写入 `config.json`: + +```yaml +channel_list: + mqtt: + settings: + username: your_username + password: your_password +``` + +### 字段说明 + +| 字段 | 位置 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `broker` | `settings` | 是 | — | MQTT Broker 地址,如 `tcp://host:1883`、`ssl://host:8883` | +| `agent_id` | `settings` | 是 | — | Agent 标识,作为 topic 路径的一部分 | +| `topic_prefix` | `settings` | 否 | `/picoclaw` | Topic 命名空间前缀 | +| `username` | `.security.yml` | 否 | — | Broker 认证用户名 | +| `password` | `.security.yml` | 否 | — | Broker 认证密码 | +| `client_id` | `settings` | 否 | 自动生成 | 发送给 Broker 的 paho 客户端 ID。未配置时自动生成为 `picoclaw-mqtt-{agent_id}-{8位hex}`,进程生命周期内固定不变,断线重连时复用同一 ID | +| `keep_alive` | `settings` | 否 | `60` | MQTT 心跳间隔(秒) | +| `qos` | `settings` | 否 | `0` | 发布和订阅的 QoS 级别:`0`、`1` 或 `2` | + +### 环境变量 + +所有字段均可通过环境变量配置: + +| 环境变量 | 对应字段 | +|----------|----------| +| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` | +| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` | +| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` | +| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` | +| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` | +| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` | +| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` | +| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` | + +--- + +## 🔄 断线重连 + +连接断开后 PicoClaw 会自动以 5 秒间隔重连 Broker,重连成功后自动重新订阅。断线重连时复用相同的 Broker 客户端 ID,Broker 能正确识别为同一连接。 + +--- + +## ⚠️ 注意事项 + +- **TLS**:支持 SSL/TLS(Broker 地址使用 `ssl://`),默认跳过证书验证。 +- **流式响应**:流式输出时会向 response topic 发送多条消息,客户端按顺序拼接即为完整回复。 +- **client_id 与会话 ID 的区别**:topic 路径中的 `client_id` 由客户端应用自行设置,用于区分会话;它与 PicoClaw paho 连接 Broker 时使用的客户端 ID 是两个独立的概念。 +- **多实例部署**:若多个 PicoClaw 实例使用相同 `agent_id` 连接同一 Broker,需为每个实例配置不同的 `client_id` 以避免 Broker 层面的冲突。 diff --git a/docs/guides/chat-apps.fr.md b/docs/guides/chat-apps.fr.md index d9112c595..a03141e5e 100644 --- a/docs/guides/chat-apps.fr.md +++ b/docs/guides/chat-apps.fr.md @@ -4,7 +4,7 @@ ## 💬 Applications de Chat -Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam. +Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT ou MaixCam. > **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé. @@ -23,6 +23,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din | **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) | | **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) | | **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) | +| **MQTT** | ⭐ Facile | N'importe quel client MQTT via broker pub/sub | [Documentation](../channels/mqtt/README.fr.md) | | **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) | | **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | | @@ -681,3 +682,67 @@ picoclaw gateway ``` + + +
+MQTT + +N'importe quel client MQTT peut communiquer avec PicoClaw via un broker. Les appareils ou services publient des requêtes vers le broker ; PicoClaw s'abonne, les traite et publie les réponses en retour. + +**1. Configurer** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://votre-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +Nom d'utilisateur et mot de passe dans `~/.picoclaw/.security.yml` : + +```yaml +channel_list: + mqtt: + settings: + username: votre_utilisateur + password: votre_mot_de_passe +``` + +**Format des topics** + +``` +{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client +``` + +Le `client_id` est défini par votre application cliente pour identifier les appareils ou sessions. + +**2. Lancer** + +```bash +picoclaw gateway +``` + +**3. Tester** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "Bonjour"}' + +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +Pour les options complètes, voir [Documentation du canal MQTT](../channels/mqtt/README.fr.md). + +
diff --git a/docs/guides/chat-apps.ja.md b/docs/guides/chat-apps.ja.md index 49c41a66e..cc9671bd5 100644 --- a/docs/guides/chat-apps.ja.md +++ b/docs/guides/chat-apps.ja.md @@ -25,6 +25,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして | **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) | | **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) | | **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) | +| **MQTT** | ⭐ 簡単 | ブローカー経由で任意の MQTT クライアントと通信 | [ドキュメント](../channels/mqtt/README.ja.md) | | **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) | | **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | | @@ -670,3 +671,67 @@ picoclaw gateway ``` + + +
+MQTT + +任意の MQTT クライアントがブローカーを介して PicoClaw と通信できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。 + +**1. 設定** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +ユーザー名とパスワードは `~/.picoclaw/.security.yml` に記載します: + +```yaml +channel_list: + mqtt: + settings: + username: your_username + password: your_password +``` + +**トピック形式** + +``` +{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント +``` + +`client_id` はクライアントアプリケーションがデバイスやセッションを識別するために設定します。 + +**2. 起動** + +```bash +picoclaw gateway +``` + +**3. テスト** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "こんにちは"}' + +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +完全な設定オプションは [MQTT チャンネルドキュメント](../channels/mqtt/README.ja.md) を参照してください。 + +
diff --git a/docs/guides/chat-apps.md b/docs/guides/chat-apps.md index 62418f91a..4fcf12653 100644 --- a/docs/guides/chat-apps.md +++ b/docs/guides/chat-apps.md @@ -4,7 +4,7 @@ ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol) +Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, or Pico (native protocol) > **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery. @@ -23,6 +23,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, | **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) | | **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) | | **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) | +| **MQTT** | ⭐ Easy | Any MQTT client via broker pub/sub | [Docs](../channels/mqtt/README.md) | | **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) | | **Pico** | ⭐ Easy | Native PicoClaw protocol channel | | @@ -587,3 +588,69 @@ picoclaw gateway ``` + + +
+MQTT + +Any MQTT client can communicate with PicoClaw via a broker. Devices or services publish requests to the broker; PicoClaw subscribes, processes them, and publishes responses back. + +**1. Configure** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +Username and password go in `~/.picoclaw/.security.yml`: + +```yaml +channel_list: + mqtt: + settings: + username: your_username + password: your_password +``` + +**Topic format** + +``` +{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client +``` + +`client_id` is set by your client application to identify different devices or sessions. + +**2. Run** + +```bash +picoclaw gateway +``` + +**3. Test** + +```bash +# Send a message +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "Hello"}' + +# Subscribe to responses +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +For full configuration options see [MQTT Channel Docs](../channels/mqtt/README.md). + +
diff --git a/docs/guides/chat-apps.ms.md b/docs/guides/chat-apps.ms.md index 6bfa7565e..03e8d36ca 100644 --- a/docs/guides/chat-apps.ms.md +++ b/docs/guides/chat-apps.ms.md @@ -4,7 +4,7 @@ ## 💬 Aplikasi Sembang -Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, atau Pico (protokol asli) +Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, atau Pico (protokol asli) > **Nota**: Semua saluran berasaskan webhook (LINE, WeCom, dan sebagainya) diservis pada satu pelayan HTTP Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Tiada port khusus per saluran untuk dikonfigurasikan. Nota: Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP webhook yang dikongsi. @@ -22,6 +22,7 @@ Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, Di | **Slack** | Sederhana (Bot token + App token) | | **IRC** | Sederhana (pelayan + konfigurasi TLS) | | **OneBot** | Sederhana (QQ melalui protokol OneBot) | +| **MQTT** | Mudah (broker + agent_id) | | **MaixCam** | Mudah (integrasi perkakasan Sipeed) | | **Pico** | Protokol PicoClaw asli | @@ -445,3 +446,67 @@ picoclaw gateway > **Nota**: WeCom AI Bot menggunakan protokol streaming pull — tiada isu timeout balasan. Tugasan panjang (>30 saat) akan bertukar secara automatik kepada penghantaran push `response_url`. + + +
+MQTT + +Mana-mana client MQTT boleh berkomunikasi dengan PicoClaw melalui broker. Peranti atau perkhidmatan menerbitkan permintaan ke broker; PicoClaw melanggan, memproses dan menerbitkan respons kembali. + +**1. Konfigurasi** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +Nama pengguna dan kata laluan dalam `~/.picoclaw/.security.yml`: + +```yaml +channel_list: + mqtt: + settings: + username: nama_pengguna + password: kata_laluan +``` + +**Format topik** + +``` +{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client +``` + +`client_id` ditetapkan oleh aplikasi client anda untuk mengenal pasti peranti atau sesi. + +**2. Jalankan** + +```bash +picoclaw gateway +``` + +**3. Uji** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "Helo"}' + +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +Untuk semua pilihan konfigurasi, lihat [Dokumentasi Saluran MQTT](../channels/mqtt/README.md). + +
diff --git a/docs/guides/chat-apps.pt-br.md b/docs/guides/chat-apps.pt-br.md index 6d4fbdc23..f6b89ca3b 100644 --- a/docs/guides/chat-apps.pt-br.md +++ b/docs/guides/chat-apps.pt-br.md @@ -4,7 +4,7 @@ ## 💬 Aplicativos de Chat -Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam +Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT ou MaixCam > **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado. @@ -23,6 +23,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D | **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) | | **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) | | **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) | +| **MQTT** | ⭐ Fácil | Qualquer cliente MQTT via broker pub/sub | [Documentação](../channels/mqtt/README.pt-br.md) | | **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) | | **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | | @@ -695,3 +696,67 @@ picoclaw gateway ``` + + +
+MQTT + +Qualquer cliente MQTT pode se comunicar com o PicoClaw via broker. Dispositivos ou serviços publicam requisições para o broker; o PicoClaw assina, processa e publica as respostas de volta. + +**1. Configurar** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://seu-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +Nome de usuário e senha em `~/.picoclaw/.security.yml`: + +```yaml +channel_list: + mqtt: + settings: + username: seu_usuario + password: sua_senha +``` + +**Formato dos tópicos** + +``` +{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente +``` + +O `client_id` é definido pela sua aplicação cliente para identificar dispositivos ou sessões. + +**2. Iniciar** + +```bash +picoclaw gateway +``` + +**3. Testar** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "Olá"}' + +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +Para todas as opções de configuração, veja a [Documentação do Canal MQTT](../channels/mqtt/README.pt-br.md). + +
diff --git a/docs/guides/chat-apps.vi.md b/docs/guides/chat-apps.vi.md index 8d0b4ee32..8071c9d3d 100644 --- a/docs/guides/chat-apps.vi.md +++ b/docs/guides/chat-apps.vi.md @@ -4,7 +4,7 @@ ## 💬 Ứng Dụng Chat -Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot hoặc MaixCam +Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT hoặc MaixCam > **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung. @@ -23,6 +23,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix | **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) | | **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) | | **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) | +| **MQTT** | ⭐ Dễ | Bất kỳ client MQTT nào qua broker pub/sub | [Tài liệu](../channels/mqtt/README.vi.md) | | **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) | | **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | | @@ -696,3 +697,67 @@ picoclaw gateway ``` + + +
+MQTT + +Bất kỳ client MQTT nào đều có thể giao tiếp với PicoClaw qua broker. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại. + +**1. Cấu hình** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +Tên người dùng và mật khẩu trong `~/.picoclaw/.security.yml`: + +```yaml +channel_list: + mqtt: + settings: + username: ten_nguoi_dung + password: mat_khau +``` + +**Định dạng topic** + +``` +{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client +``` + +`client_id` do ứng dụng client đặt để phân biệt thiết bị hoặc phiên. + +**2. Khởi động** + +```bash +picoclaw gateway +``` + +**3. Kiểm tra** + +```bash +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "Xin chào"}' + +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +Xem đầy đủ tùy chọn cấu hình tại [Tài liệu Kênh MQTT](../channels/mqtt/README.vi.md). + +
diff --git a/docs/guides/chat-apps.zh.md b/docs/guides/chat-apps.zh.md index b5891dc69..d7400cd83 100644 --- a/docs/guides/chat-apps.zh.md +++ b/docs/guides/chat-apps.zh.md @@ -4,7 +4,7 @@ ## 💬 聊天应用集成 (Chat Apps) -PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 +PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方,包括 Telegram、Discord、WhatsApp、微信、QQ、钉钉、LINE、企业微信、飞书、Slack、IRC、OneBot、MQTT、MaixCam 等。 > **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。 @@ -25,6 +25,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) | | **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) | | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) | +| **MQTT** | ⭐ 简单 | 任意 MQTT 客户端通过 Broker 收发消息 | [查看文档](../channels/mqtt/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) | | **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | | @@ -610,3 +611,69 @@ picoclaw gateway ``` + + +
+MQTT + +任意 MQTT 客户端均可通过 Broker 与 PicoClaw 通信。设备或服务向 Broker 发布请求,PicoClaw 订阅后处理并将响应发布回去。 + +**1. 配置** + +```json +{ + "channel_list": { + "mqtt": { + "enabled": true, + "type": "mqtt", + "settings": { + "broker": "ssl://your-broker:8883", + "agent_id": "assistant", + "topic_prefix": "/picoclaw", + "keep_alive": 60, + "qos": 0 + } + } + } +} +``` + +用户名和密码存储于 `~/.picoclaw/.security.yml`: + +```yaml +channel_list: + mqtt: + settings: + username: your_username + password: your_password +``` + +**Topic 格式** + +``` +{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw +{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端 +``` + +`client_id` 由客户端自行指定,用于区分不同设备或会话。 + +**2. 运行** + +```bash +picoclaw gateway +``` + +**3. 测试** + +```bash +# 发送消息 +mosquitto_pub -t "/picoclaw/assistant/device1/request" \ + -m '{"text": "你好"}' + +# 订阅响应 +mosquitto_sub -t "/picoclaw/assistant/device1/response" +``` + +完整配置选项请参考 [MQTT 渠道文档](../channels/mqtt/README.zh.md)。 + +
diff --git a/docs/security/security_configuration.md b/docs/security/security_configuration.md index 065eb1e76..ad4b4f183 100644 --- a/docs/security/security_configuration.md +++ b/docs/security/security_configuration.md @@ -89,6 +89,13 @@ channels: nickserv_password: "your-irc-nickserv-password" sasl_password: "your-irc-sasl-password" +# Channel Settings (nested format for channels that use settings block) +channel_list: + mqtt: + settings: + username: "your-mqtt-username" + password: "your-mqtt-password" + # Web Tool API Keys web: brave: @@ -226,6 +233,19 @@ channels: - `channels.feishu.app_secret` → `config.channels.feishu.app_secret` - etc. +Channels that use a `settings` block (e.g. MQTT) use the `channel_list` key instead: + +```yaml +channel_list: + mqtt: + settings: + username: "value" + password: "value" +``` + +- `channel_list.mqtt.settings.username` → `config.channel_list.mqtt.settings.username` +- `channel_list.mqtt.settings.password` → `config.channel_list.mqtt.settings.password` + ### Web Tools **Brave, Tavily, Perplexity:** diff --git a/go.mod b/go.mod index c7e77c0f9..bc5874870 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ require ( github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index 5cd39ec8d..18dced20d 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28= diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 1cab1a4a6..c3decd242 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -1310,6 +1310,7 @@ make test # Full test suite | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) | | `pkg/channels/maixcam/` | `"maixcam"` | — | +| `pkg/channels/mqtt/` | `"mqtt"` | — | | `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler | ### A.3 Interface Quick Reference diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index c44859c20..d71c30104 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -1308,6 +1308,7 @@ make test # 全量测试 | `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) | | `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) | | `pkg/channels/maixcam/` | `"maixcam"` | — | +| `pkg/channels/mqtt/` | `"mqtt"` | — | | `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler | ### A.3 接口速查表 diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index d56c4fd9b..472849e66 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -612,6 +612,8 @@ func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channe return bc, settings.Token.String() != "" case *config.VKSettings: return bc, settings.GroupID != 0 && settings.Token.String() != "" + case *config.MQTTSettings: + return bc, settings.Broker != "" && settings.AgentID != "" } return bc, bc.Enabled diff --git a/pkg/channels/mqtt/init.go b/pkg/channels/mqtt/init.go new file mode 100644 index 000000000..c9cec7e83 --- /dev/null +++ b/pkg/channels/mqtt/init.go @@ -0,0 +1,16 @@ +package mqtt + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterSafeFactory( + config.ChannelMQTT, + func(bc *config.Channel, cfg *config.MQTTSettings, b *bus.MessageBus) (channels.Channel, error) { + return NewMQTTChannel(bc, cfg, b) + }, + ) +} diff --git a/pkg/channels/mqtt/mqtt.go b/pkg/channels/mqtt/mqtt.go new file mode 100644 index 000000000..d183fcc3e --- /dev/null +++ b/pkg/channels/mqtt/mqtt.go @@ -0,0 +1,242 @@ +package mqtt + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + pahomqtt "github.com/eclipse/paho.mqtt.golang" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// mqttPayload is the JSON payload for both inbound and outbound messages. +type mqttPayload struct { + Text string `json:"text"` +} + +// MQTTChannel implements the Channel interface for MQTT-based communication. +type MQTTChannel struct { + *channels.BaseChannel + bc *config.Channel + cfg *config.MQTTSettings + client pahomqtt.Client + qos byte + clientID string +} + +// NewMQTTChannel creates a new MQTT channel instance. +func NewMQTTChannel(bc *config.Channel, cfg *config.MQTTSettings, b *bus.MessageBus) (*MQTTChannel, error) { + if cfg.Broker == "" { + return nil, fmt.Errorf("mqtt broker is required") + } + if cfg.AgentID == "" { + return nil, fmt.Errorf("mqtt agent_id is required") + } + + base := channels.NewBaseChannel("mqtt", cfg, b, bc.AllowFrom, + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), + ) + + mqttClientID := cfg.ClientID + if mqttClientID == "" { + var suffix [4]byte + _, _ = rand.Read(suffix[:]) + mqttClientID = fmt.Sprintf("picoclaw-mqtt-%s-%s", cfg.AgentID, hex.EncodeToString(suffix[:])) + } + + return &MQTTChannel{ + BaseChannel: base, + bc: bc, + cfg: cfg, + qos: byte(cfg.QoS), + clientID: mqttClientID, + }, nil +} + +// Start connects to the MQTT broker and begins listening for inbound messages. +func (c *MQTTChannel) Start(ctx context.Context) error { + logger.InfoC("mqtt", "Starting MQTT channel") + + keepAlive := c.cfg.KeepAlive + if keepAlive <= 0 { + keepAlive = 60 + } + + opts := pahomqtt.NewClientOptions() + opts.AddBroker(c.cfg.Broker) + opts.SetClientID(c.clientID) + opts.SetKeepAlive(time.Duration(keepAlive) * time.Second) + opts.SetAutoReconnect(true) + opts.SetConnectRetry(true) + opts.SetConnectRetryInterval(5 * time.Second) + opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec + + if c.cfg.Username.String() != "" { + opts.SetUsername(c.cfg.Username.String()) + opts.SetPassword(c.cfg.Password.String()) + } + + opts.SetOnConnectHandler(func(client pahomqtt.Client) { + logger.InfoC("mqtt", "MQTT connected, subscribing to inbound topic") + c.subscribe(client) + }) + + opts.SetConnectionLostHandler(func(_ pahomqtt.Client, err error) { + logger.WarnCF("mqtt", "MQTT connection lost", map[string]any{"error": err.Error()}) + }) + + client := pahomqtt.NewClient(opts) + token := client.Connect() + if !token.WaitTimeout(10 * time.Second) { + return fmt.Errorf("mqtt connect timed out after 10s (broker: %s)", c.cfg.Broker) + } + if err := token.Error(); err != nil { + return fmt.Errorf("mqtt connect failed: %w", err) + } + + c.client = client + c.SetRunning(true) + + logger.InfoCF("mqtt", "MQTT channel started", map[string]any{ + "broker": c.cfg.Broker, + "agent_id": c.cfg.AgentID, + }) + return nil +} + +// topicPrefix returns the configured topic prefix, normalizing slashes. +// Trailing slashes are stripped; the result may or may not have a leading slash +// depending on what the user configured. +func (c *MQTTChannel) topicPrefix() string { + p := strings.TrimRight(c.cfg.TopicPrefix, "/") + if p == "" { + return "/picoclaw" + } + return p +} + +// clientIDFromTopic extracts the client_id segment from a received topic. +// Topic structure: {prefix}/{agent_id}/{client_id}/request +func (c *MQTTChannel) clientIDFromTopic(topic string) (string, bool) { + prefix := c.topicPrefix() + // Build the expected fixed portion: {prefix}/{agent_id}/ + fixed := prefix + "/" + c.cfg.AgentID + "/" + after, ok := strings.CutPrefix(topic, fixed) + if !ok { + return "", false + } + // after = "{client_id}/request" + slash := strings.IndexByte(after, '/') + if slash < 0 { + return "", false + } + return after[:slash], true +} + +// subscribe subscribes to the inbound topic for this agent. +func (c *MQTTChannel) subscribe(client pahomqtt.Client) { + topic := fmt.Sprintf("%s/%s/+/request", c.topicPrefix(), c.cfg.AgentID) + token := client.Subscribe(topic, c.qos, func(_ pahomqtt.Client, msg pahomqtt.Message) { + c.handleInbound(msg) + }) + token.Wait() + if err := token.Error(); err != nil { + logger.ErrorCF("mqtt", "Failed to subscribe", map[string]any{ + "topic": topic, + "error": err.Error(), + }) + } else { + logger.InfoCF("mqtt", "Subscribed to inbound topic", map[string]any{"topic": topic}) + } +} + +// handleInbound processes an inbound MQTT message. +func (c *MQTTChannel) handleInbound(msg pahomqtt.Message) { + topic := msg.Topic() + + clientID, ok := c.clientIDFromTopic(topic) + if !ok { + logger.WarnCF("mqtt", "Unexpected topic format", map[string]any{"topic": topic}) + return + } + chatID := "mqtt:" + clientID + + var payload mqttPayload + if err := json.Unmarshal(msg.Payload(), &payload); err != nil { + logger.WarnCF("mqtt", "Failed to parse inbound payload", map[string]any{ + "topic": topic, + "error": err.Error(), + }) + return + } + + if payload.Text == "" { + logger.WarnCF("mqtt", "Inbound payload missing text", map[string]any{"topic": topic}) + return + } + + inboundCtx := bus.InboundContext{ + Channel: "mqtt", + ChatID: chatID, + ChatType: "direct", + SenderID: clientID, + } + + c.HandleInboundContext(context.Background(), chatID, payload.Text, nil, inboundCtx) +} + +// Stop disconnects from the MQTT broker. +func (c *MQTTChannel) Stop(_ context.Context) error { + logger.InfoC("mqtt", "Stopping MQTT channel") + c.SetRunning(false) + + if c.client != nil && c.client.IsConnected() { + c.client.Disconnect(500) + } + + logger.InfoC("mqtt", "MQTT channel stopped") + return nil +} + +// Send publishes a response to the client via MQTT. +func (c *MQTTChannel) Send(_ context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + if strings.TrimSpace(msg.Content) == "" { + return nil, nil + } + + clientID := strings.TrimPrefix(msg.ChatID, "mqtt:") + if clientID == msg.ChatID { + logger.WarnCF("mqtt", "Send called with unexpected chatID format", map[string]any{"chat_id": msg.ChatID}) + return nil, nil + } + + topic := fmt.Sprintf("%s/%s/%s/response", c.topicPrefix(), c.cfg.AgentID, clientID) + + data, err := json.Marshal(mqttPayload{Text: msg.Content}) + if err != nil { + return nil, fmt.Errorf("mqtt: failed to marshal outbound payload: %w", err) + } + + token := c.client.Publish(topic, c.qos, false, data) + token.Wait() + if err := token.Error(); err != nil { + return nil, fmt.Errorf("mqtt: publish failed: %w", err) + } + + logger.DebugCF("mqtt", "Published response", map[string]any{"topic": topic}) + return nil, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index dc9e88949..cf8422ab0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -512,6 +512,17 @@ type TeamsWebhookTarget struct { Title string `json:"title,omitempty" yaml:"-"` } +type MQTTSettings struct { + Broker string `json:"broker" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_BROKER"` + AgentID string `json:"agent_id" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_AGENT_ID"` + TopicPrefix string `json:"topic_prefix,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX"` + Username SecureString `json:"username,omitzero" yaml:"username,omitempty" env:"PICOCLAW_CHANNELS_MQTT_USERNAME"` + Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_MQTT_PASSWORD"` + ClientID string `json:"client_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_CLIENT_ID"` + KeepAlive int `json:"keep_alive,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE"` + QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go index 4e87fcc3e..fe7ee4b98 100644 --- a/pkg/config/config_channel.go +++ b/pkg/config/config_channel.go @@ -33,6 +33,7 @@ const ( ChannelWhatsApp = "whatsapp" ChannelWhatsAppNative = "whatsapp_native" ChannelTeamsWebHook = "teams_webhook" + ChannelMQTT = "mqtt" ) func initChannel() { @@ -640,6 +641,7 @@ var channelSettingsFactory = map[string]any{ ChannelWhatsApp: (WhatsAppSettings{}), ChannelWhatsAppNative: (WhatsAppSettings{}), ChannelTeamsWebHook: (TeamsWebhookSettings{}), + ChannelMQTT: (MQTTSettings{}), } // newChannelSettings creates a fresh zero-value pointer for the given channel type. diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index f58590d5b..c7ea7fc71 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -25,6 +25,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" + _ "github.com/sipeed/picoclaw/pkg/channels/mqtt" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" diff --git a/web/README.md b/web/README.md index 2a57524e0..87760cd94 100644 --- a/web/README.md +++ b/web/README.md @@ -47,7 +47,7 @@ The current frontend exposes these major pages and flows: - Current built-in flows: OpenAI, Anthropic, and Google Antigravity. - `/channels/*` - Configure supported channels from a shared catalog. - - Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`. + - Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`, `mqtt`. - Includes QR-based binding helpers for WeChat and WeCom. - `/agent/skills` - Browse built-in, global, and workspace skills. diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go index 82cd54b72..e77b11f8b 100644 --- a/web/backend/api/channels.go +++ b/web/backend/api/channels.go @@ -30,6 +30,7 @@ var channelCatalog = []channelCatalogItem{ {Name: "maixcam", ConfigKey: "maixcam"}, {Name: "matrix", ConfigKey: "matrix"}, {Name: "irc", ConfigKey: "irc"}, + {Name: "mqtt", ConfigKey: "mqtt"}, } type channelConfigResponse struct { @@ -106,6 +107,7 @@ var channelSecretFieldMap = map[string][]string{ "whatsapp": {}, "whatsapp_native": {}, "maixcam": {}, + "mqtt": {"username", "password"}, } func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse { diff --git a/web/frontend/src/components/channels/channel-config-fields.ts b/web/frontend/src/components/channels/channel-config-fields.ts index 35356954b..cf8f50adf 100644 --- a/web/frontend/src/components/channels/channel-config-fields.ts +++ b/web/frontend/src/components/channels/channel-config-fields.ts @@ -14,6 +14,7 @@ export const SECRET_FIELD_MAP = { encrypt_key: "_encrypt_key", verification_token: "_verification_token", secret: "_secret", + username: "_username", password: "_password", nickserv_password: "_nickserv_password", sasl_password: "_sasl_password", @@ -33,6 +34,7 @@ const CHANNEL_SECRET_FIELDS: Record = { pico: ["token"], matrix: ["access_token"], irc: ["password", "nickserv_password", "sasl_password"], + mqtt: ["username", "password"], } const SECRET_FIELD_SET = new Set(Object.keys(SECRET_FIELD_MAP)) diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index d253980f8..8a8300d08 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -24,6 +24,7 @@ import { getChannelDisplayName } from "@/components/channels/channel-display-nam import { DiscordForm } from "@/components/channels/channel-forms/discord-form" import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" import { GenericForm } from "@/components/channels/channel-forms/generic-form" +import { MqttForm } from "@/components/channels/channel-forms/mqtt-form" import { SlackForm } from "@/components/channels/channel-forms/slack-form" import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" import { WecomForm } from "@/components/channels/channel-forms/wecom-form" @@ -215,6 +216,8 @@ function isConfigured( ) case "irc": return hasValue("server") + case "mqtt": + return hasValue("broker") && hasValue("agent_id") default: return false } @@ -250,6 +253,8 @@ function getRequiredFieldKeys(channelName: string): string[] { return ["homeserver", "user_id", "access_token"] case "irc": return ["server"] + case "mqtt": + return ["broker", "agent_id"] default: return [] } @@ -279,6 +284,7 @@ const CHANNELS_WITHOUT_DOCS = new Set([ "irc", "whatsapp", "whatsapp_native", + "mqtt", ]) export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { @@ -618,6 +624,15 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { arrayFieldResetVersion={arrayFieldResetVersion} /> ) + case "mqtt": + return ( + + ) case "weixin": return ( void + configuredSecrets: string[] + fieldErrors?: Record +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asNumber(value: unknown): string { + if (typeof value === "number") return String(value) + if (typeof value === "string" && value !== "") return value + return "" +} + +function CodeLine({ children }: { children: string }) { + return ( + + {children} + + ) +} + +export function MqttForm({ + config, + onChange, + configuredSecrets, + fieldErrors = {}, +}: MqttFormProps) { + const { t } = useTranslation() + const prefix = asString(config.topic_prefix) || "/picoclaw" + const agentID = asString(config.agent_id) || "{agent_id}" + const topicBase = `${prefix}/${agentID}/{client_id}` + + return ( +
+ + + + onChange("broker", e.target.value)} + placeholder="mqtt://broker.example.com:1883" + /> + + + + onChange("agent_id", e.target.value)} + placeholder="my-agent" + /> + + + + onChange("topic_prefix", e.target.value)} + placeholder="/picoclaw" + /> + + + + + + + + onChange("_username", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "username", + t("channels.mqtt.secretSet"), + t("channels.mqtt.secretEmpty"), + )} + /> + + + + onChange("_password", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "password", + t("channels.mqtt.secretSet"), + t("channels.mqtt.secretEmpty"), + )} + /> + + + + + + + + onChange("client_id", e.target.value)} + placeholder={t("channels.mqtt.clientIdPlaceholder")} + /> + + + + onChange("keep_alive", Number(e.target.value))} + placeholder="60" + /> + + + + onChange("qos", Number(e.target.value))} + placeholder="0" + /> + + + + + + + + {t("channels.mqtt.protocolTitle")} + + + {t("channels.mqtt.protocolDesc")} + + + +
+

+ {t("channels.mqtt.uplink")} +

+ {`${topicBase}/request`} +
+              {`{\n  "text": "your message"\n}`}
+            
+
+

+ + {t("channels.mqtt.fieldText")} + + {" — "} + {t("channels.mqtt.uplinkTextDesc")} +

+
+
+ +
+

+ {t("channels.mqtt.downlink")} +

+ {`${topicBase}/response`} +
+              {`{\n  "text": "agent response"\n}`}
+            
+
+

+ + {t("channels.mqtt.fieldText")} + + {" — "} + {t("channels.mqtt.downlinkTextDesc")} +

+
+
+ +
+

+ {t("channels.mqtt.topicParams")} +

+
+

+ + {prefix} + + {" — "} + {t("channels.mqtt.topicPrefixDesc")} +

+

+ + {agentID} + + {" — "} + {t("channels.mqtt.agentIdDesc")} +

+

+ + {"{client_id}"} + + {" — "} + {t("channels.mqtt.clientIdDesc")} +

+
+
+
+
+
+ ) +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 4e7a0c818..f79aed1a5 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -325,7 +325,8 @@ "maixcam": "MaixCam", "matrix": "Matrix", "irc": "IRC", - "weixin": "WeChat" + "weixin": "WeChat", + "mqtt": "MQTT" }, "weixin": { "bindTitle": "WeChat Account Binding", @@ -450,11 +451,35 @@ "channels": "IRC channels to join.", "requestCaps": "IRC capability list requested on connect.", "maxBase64FileSizeMiB": "Maximum size in MiB for converting local files to base64 before upload. 0 means unlimited. Applies only to local files, not URL uploads.", - "genericField": "Used to configure {{field}}." + "genericField": "Used to configure {{field}}.", + "broker": "MQTT broker address.", + "mqttAgentId": "Unique identifier for this instance, used to build the topic path.", + "topicPrefix": "Topic prefix. Defaults to /picoclaw.", + "mqttUsername": "Broker authentication username (optional).", + "mqttPassword": "Broker authentication password (optional).", + "mqttClientId": "MQTT client ID. Leave blank to auto-generate.", + "keepAlive": "Keepalive interval in seconds. Defaults to 60.", + "qos": "Message quality of service level: 0 = at most once, 1 = at least once, 2 = exactly once." } }, "validation": { "requiredField": "This field is required." + }, + "mqtt": { + "protocolTitle": "Protocol Reference", + "protocolDesc": "Clients send and receive messages using the following topic and payload format.", + "uplink": "Uplink (Client → Agent)", + "downlink": "Downlink (Agent → Client)", + "topicParams": "Topic Parameters", + "fieldText": "text", + "uplinkTextDesc": "Natural language instruction from the user (required).", + "downlinkTextDesc": "Agent reply text. In streaming mode, concatenate multiple messages in order for the full response.", + "topicPrefixDesc": "Topic prefix, matches the configuration above.", + "agentIdDesc": "Agent ID, matches the configuration above.", + "clientIdDesc": "Client-defined identifier. Recommended: generate a UUID on first launch and persist it so the same device always uses the same ID.", + "clientIdPlaceholder": "Auto-generated if blank", + "secretSet": "Already configured. Leave blank to keep unchanged.", + "secretEmpty": "Not configured" } }, "pages": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index fa7d56418..571232b67 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -325,7 +325,8 @@ "maixcam": "MaixCam", "matrix": "Matrix", "irc": "IRC", - "weixin": "微信" + "weixin": "微信", + "mqtt": "MQTT" }, "weixin": { "bindTitle": "微信账号绑定", @@ -450,11 +451,35 @@ "channels": "要加入的 IRC 频道列表", "requestCaps": "连接时请求的 IRC 扩展能力列表", "maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传", - "genericField": "用于配置{{field}}" + "genericField": "用于配置{{field}}", + "broker": "MQTT Broker 地址。", + "mqttAgentId": "本实例的唯一标识,用于构造 topic 路径。", + "topicPrefix": "Topic 前缀,默认为 /picoclaw。", + "mqttUsername": "Broker 认证用户名(可选)。", + "mqttPassword": "Broker 认证密码(可选)。", + "mqttClientId": "MQTT 客户端 ID,留空自动生成。", + "keepAlive": "心跳间隔(秒),默认 60。", + "qos": "消息质量等级:0=最多一次,1=至少一次,2=恰好一次。" } }, "validation": { "requiredField": "请填写该字段" + }, + "mqtt": { + "protocolTitle": "接入协议", + "protocolDesc": "客户端按以下 topic 和 payload 格式收发消息", + "uplink": "上行(客户端 → Agent)", + "downlink": "下行(Agent → 客户端)", + "topicParams": "Topic 参数说明", + "fieldText": "text", + "uplinkTextDesc": "用户自然语言指令(必填)", + "downlinkTextDesc": "Agent 回复文本,流式场景下多条按序拼接即为完整回复", + "topicPrefixDesc": "topic 前缀,与上方配置一致", + "agentIdDesc": "Agent ID,与上方配置一致", + "clientIdDesc": "客户端自定义标识,建议首次启动时生成 UUID 并持久化,同一设备保持不变", + "clientIdPlaceholder": "留空自动生成", + "secretSet": "已设置,留空表示不修改", + "secretEmpty": "未配置" } }, "pages": { From ad5232ade8035012facaab0f3fae5bb2406d8599 Mon Sep 17 00:00:00 2001 From: Andy Lo-A-Foe Date: Thu, 23 Apr 2026 23:27:55 +0200 Subject: [PATCH 22/71] feat(bedrock): implement StreamingProvider for real-time token streaming Adds ConverseStream API support to the Bedrock provider, implementing the StreamingProvider interface. Tokens flow via onChunk callback for real-time delivery to streaming-capable channels. - Extract buildConverseParams to share request logic between Chat and ChatStream - Add converseStreamReader interface for testability - Preserve raw payload in Arguments on JSON parse failure - Ensure Function.Arguments is always valid JSON - Streaming timeout only applied when explicitly configured - Capture stream Close() errors for diagnostics - Consistent "bedrock conversestream" / "bedrock:" log prefixes Co-Authored-By: Claude Opus 4.6 --- pkg/providers/bedrock/provider_bedrock.go | 284 ++++++++++++++++++---- 1 file changed, 239 insertions(+), 45 deletions(-) diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go index 3798c5fd8..ee0ac75a0 100644 --- a/pkg/providers/bedrock/provider_bedrock.go +++ b/pkg/providers/bedrock/provider_bedrock.go @@ -135,48 +135,23 @@ func NewProvider(ctx context.Context, opts ...Option) (*Provider, error) { }, nil } -// Chat sends messages to AWS Bedrock using the Converse API. -func (p *Provider) Chat( - ctx context.Context, - messages []Message, - tools []ToolDefinition, - model string, - options map[string]any, -) (*LLMResponse, error) { - // Apply request timeout if context doesn't already have a deadline. - // Use explicit timeout if set, otherwise fall back to common default. - effectiveTimeout := p.requestTimeout - if effectiveTimeout <= 0 { - effectiveTimeout = common.DefaultRequestTimeout - } - if _, hasDeadline := ctx.Deadline(); !hasDeadline { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, effectiveTimeout) - defer cancel() - } +// converseParams holds the shared request parameters for Converse and ConverseStream. +type converseParams struct { + messages []types.Message + system []types.SystemContentBlock + inferenceConfig *types.InferenceConfiguration + toolConfig *types.ToolConfiguration +} - // Build the Converse API input - input := &bedrockruntime.ConverseInput{ - ModelId: aws.String(model), - } - - // Convert messages to Bedrock format +func buildConverseParams(messages []Message, tools []ToolDefinition, options map[string]any) converseParams { bedrockMessages, systemPrompts := convertMessages(messages) - input.Messages = bedrockMessages - // Set system prompts if any - if len(systemPrompts) > 0 { - input.System = systemPrompts - } - - // Set inference configuration only when options are provided var inferenceConfig *types.InferenceConfiguration if maxTokens, ok := common.AsInt(options["max_tokens"]); ok && maxTokens > 0 { if inferenceConfig == nil { inferenceConfig = &types.InferenceConfiguration{} } - // Clamp to int32 range to avoid overflow if maxTokens > math.MaxInt32 { maxTokens = math.MaxInt32 } @@ -190,23 +165,53 @@ func (p *Provider) Chat( inferenceConfig.Temperature = aws.Float32(float32(temp)) } - if inferenceConfig != nil { - input.InferenceConfig = inferenceConfig - } - - // Convert tools to Bedrock format - // Only set ToolConfig if at least one valid tool was produced + var toolConfig *types.ToolConfiguration if len(tools) > 0 { - toolConfig := convertTools(tools) - if len(toolConfig.Tools) > 0 { - input.ToolConfig = toolConfig + tc := convertTools(tools) + if len(tc.Tools) > 0 { + toolConfig = tc } } - // Call Bedrock Converse API + return converseParams{ + messages: bedrockMessages, + system: systemPrompts, + inferenceConfig: inferenceConfig, + toolConfig: toolConfig, + } +} + +// Chat sends messages to AWS Bedrock using the Converse API. +func (p *Provider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + effectiveTimeout := p.requestTimeout + if effectiveTimeout <= 0 { + effectiveTimeout = common.DefaultRequestTimeout + } + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + } + + params := buildConverseParams(messages, tools, options) + input := &bedrockruntime.ConverseInput{ + ModelId: aws.String(model), + Messages: params.messages, + InferenceConfig: params.inferenceConfig, + ToolConfig: params.toolConfig, + } + if len(params.system) > 0 { + input.System = params.system + } + output, err := p.client.Converse(ctx, input) if err != nil { - // Check for SSO token expiration errors and provide actionable guidance if isSSOTokenError(err) { return nil, fmt.Errorf( "bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", @@ -216,10 +221,199 @@ func (p *Provider) Chat( return nil, fmt.Errorf("bedrock converse: %w", err) } - // Parse the response return parseResponse(output) } +// ChatStream sends messages to AWS Bedrock using the ConverseStream API. +// It streams the accumulated text so far via the onChunk callback and returns the complete response. +func (p *Provider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + if p.requestTimeout > 0 { + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, p.requestTimeout) + defer cancel() + } + } + + params := buildConverseParams(messages, tools, options) + input := &bedrockruntime.ConverseStreamInput{ + ModelId: aws.String(model), + Messages: params.messages, + InferenceConfig: params.inferenceConfig, + ToolConfig: params.toolConfig, + } + if len(params.system) > 0 { + input.System = params.system + } + + output, err := p.client.ConverseStream(ctx, input) + if err != nil { + if isSSOTokenError(err) { + return nil, fmt.Errorf( + "bedrock conversestream: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", + err, + ) + } + return nil, fmt.Errorf("bedrock conversestream: %w", err) + } + + return parseStreamResponse(ctx, output.GetStream(), onChunk) +} + +// converseStreamReader abstracts the Bedrock event stream so parseStreamResponse +// can be unit-tested with a mock event source. +type converseStreamReader interface { + Events() <-chan types.ConverseStreamOutput + Err() error + Close() error +} + +// parseStreamResponse processes the ConverseStream event stream and accumulates the response. +func parseStreamResponse( + ctx context.Context, + stream converseStreamReader, + onChunk func(accumulated string), +) (resp *LLMResponse, err error) { + if stream == nil { + return nil, fmt.Errorf("bedrock conversestream: nil event stream") + } + defer func() { + if closeErr := stream.Close(); closeErr != nil { + if err == nil { + err = fmt.Errorf("bedrock conversestream: close event stream: %w", closeErr) + } else { + log.Printf("bedrock conversestream: close event stream: %v", closeErr) + } + } + }() + + var textContent strings.Builder + finishReason := "stop" + var usage *UsageInfo + toolCalls := make([]ToolCall, 0) + + // Track active tool use blocks by index + type toolAccum struct { + id string + name string + argsJSON strings.Builder + } + activeTools := map[int]*toolAccum{} + + events := stream.Events() + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case event, ok := <-events: + if !ok { + // Stream closed + goto done + } + + switch e := event.(type) { + case *types.ConverseStreamOutputMemberContentBlockStart: + // New content block starting + if toolUse, ok := e.Value.Start.(*types.ContentBlockStartMemberToolUse); ok { + activeTools[int(aws.ToInt32(e.Value.ContentBlockIndex))] = &toolAccum{ + id: aws.ToString(toolUse.Value.ToolUseId), + name: aws.ToString(toolUse.Value.Name), + } + } + + case *types.ConverseStreamOutputMemberContentBlockDelta: + // Content delta + switch delta := e.Value.Delta.(type) { + case *types.ContentBlockDeltaMemberText: + textContent.WriteString(delta.Value) + if onChunk != nil { + onChunk(textContent.String()) + } + case *types.ContentBlockDeltaMemberToolUse: + idx := int(aws.ToInt32(e.Value.ContentBlockIndex)) + if tool, exists := activeTools[idx]; exists { + tool.argsJSON.WriteString(aws.ToString(delta.Value.Input)) + } + } + + case *types.ConverseStreamOutputMemberContentBlockStop: + // Content block finished - finalize tool if it was a tool use + idx := int(aws.ToInt32(e.Value.ContentBlockIndex)) + if tool, exists := activeTools[idx]; exists { + args := make(map[string]any) + argsStr := tool.argsJSON.String() + if argsStr != "" { + if err := json.Unmarshal([]byte(argsStr), &args); err != nil { + log.Printf("bedrock: stream: failed to parse tool arguments for %q: %v", tool.name, err) + args = map[string]any{"raw": argsStr} + } + } + funcArgs := argsStr + if argsJSON, marshalErr := json.Marshal(args); marshalErr == nil { + funcArgs = string(argsJSON) + } + toolCalls = append(toolCalls, ToolCall{ + ID: tool.id, + Name: tool.name, + Arguments: args, + Function: &FunctionCall{ + Name: tool.name, + Arguments: funcArgs, + }, + }) + delete(activeTools, idx) + } + + case *types.ConverseStreamOutputMemberMessageStop: + // Message complete + switch e.Value.StopReason { + case types.StopReasonToolUse: + finishReason = "tool_calls" + case types.StopReasonMaxTokens: + finishReason = "length" + case types.StopReasonEndTurn: + finishReason = "stop" + case types.StopReasonStopSequence: + finishReason = "stop" + case types.StopReasonContentFiltered: + finishReason = "content_filter" + default: + finishReason = "stop" + } + + case *types.ConverseStreamOutputMemberMetadata: + // Usage metadata + if e.Value.Usage != nil { + usage = &UsageInfo{ + PromptTokens: int(aws.ToInt32(e.Value.Usage.InputTokens)), + CompletionTokens: int(aws.ToInt32(e.Value.Usage.OutputTokens)), + TotalTokens: int(aws.ToInt32(e.Value.Usage.InputTokens)) + int(aws.ToInt32(e.Value.Usage.OutputTokens)), + } + } + } + } + } + +done: + if err := stream.Err(); err != nil { + return nil, fmt.Errorf("bedrock conversestream: %w", err) + } + + return &LLMResponse{ + Content: textContent.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, + }, nil +} + // GetDefaultModel returns an empty string as Bedrock models are user-configured. func (p *Provider) GetDefaultModel() string { return "" From b03fa6176429ec5c81fca93c7818aa9dea6f308b Mon Sep 17 00:00:00 2001 From: Andy Lo-A-Foe Date: Thu, 23 Apr 2026 23:27:56 +0200 Subject: [PATCH 23/71] test(bedrock): add unit tests for ChatStream/parseStreamResponse Tests cover: text-only streaming with chunk accumulation, tool call parsing with fragmented JSON, mixed text+tool responses, context cancellation, invalid JSON fallback to raw payload, nil stream guard, default finish reason, and all stop reason mappings. Co-Authored-By: Claude Opus 4.6 --- .../bedrock/provider_bedrock_test.go | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go index 38a5e26da..9d6c747f1 100644 --- a/pkg/providers/bedrock/provider_bedrock_test.go +++ b/pkg/providers/bedrock/provider_bedrock_test.go @@ -8,6 +8,7 @@ package bedrock import ( + "context" "fmt" "testing" @@ -605,3 +606,272 @@ func TestIsSSOTokenError(t *testing.T) { }) } } + +// mockStreamReader implements bedrockruntime.ConverseStreamOutputReader for testing. +type mockStreamReader struct { + ch chan types.ConverseStreamOutput + err error +} + +func (r *mockStreamReader) Events() <-chan types.ConverseStreamOutput { return r.ch } +func (r *mockStreamReader) Close() error { return nil } +func (r *mockStreamReader) Err() error { return r.err } + +func newMockStream(events []types.ConverseStreamOutput) *bedrockruntime.ConverseStreamEventStream { + ch := make(chan types.ConverseStreamOutput, len(events)) + for _, e := range events { + ch <- e + } + close(ch) + + return bedrockruntime.NewConverseStreamEventStream(func(es *bedrockruntime.ConverseStreamEventStream) { + es.Reader = &mockStreamReader{ch: ch} + }) +} + +func TestParseStreamResponse_TextOnly(t *testing.T) { + events := []types.ConverseStreamOutput{ + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + Delta: &types.ContentBlockDeltaMemberText{Value: "Hello "}, + ContentBlockIndex: aws.Int32(0), + }, + }, + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + Delta: &types.ContentBlockDeltaMemberText{Value: "World"}, + ContentBlockIndex: aws.Int32(0), + }, + }, + &types.ConverseStreamOutputMemberMessageStop{ + Value: types.MessageStopEvent{StopReason: types.StopReasonEndTurn}, + }, + &types.ConverseStreamOutputMemberMetadata{ + Value: types.ConverseStreamMetadataEvent{ + Usage: &types.TokenUsage{ + InputTokens: aws.Int32(10), + OutputTokens: aws.Int32(5), + }, + }, + }, + } + + var chunks []string + stream := newMockStream(events) + resp, err := parseStreamResponse(context.Background(), stream, func(accumulated string) { + chunks = append(chunks, accumulated) + }) + + require.NoError(t, err) + assert.Equal(t, "Hello World", resp.Content) + assert.Equal(t, "stop", resp.FinishReason) + assert.Empty(t, resp.ToolCalls) + require.NotNil(t, resp.Usage) + assert.Equal(t, 10, resp.Usage.PromptTokens) + assert.Equal(t, 5, resp.Usage.CompletionTokens) + assert.Equal(t, 15, resp.Usage.TotalTokens) + assert.Equal(t, []string{"Hello ", "Hello World"}, chunks) +} + +func TestParseStreamResponse_ToolCall(t *testing.T) { + events := []types.ConverseStreamOutput{ + &types.ConverseStreamOutputMemberContentBlockStart{ + Value: types.ContentBlockStartEvent{ + ContentBlockIndex: aws.Int32(0), + Start: &types.ContentBlockStartMemberToolUse{ + Value: types.ToolUseBlockStart{ + ToolUseId: aws.String("call_1"), + Name: aws.String("search"), + }, + }, + }, + }, + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + ContentBlockIndex: aws.Int32(0), + Delta: &types.ContentBlockDeltaMemberToolUse{ + Value: types.ToolUseBlockDelta{Input: aws.String(`{"q":`)}, + }, + }, + }, + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + ContentBlockIndex: aws.Int32(0), + Delta: &types.ContentBlockDeltaMemberToolUse{ + Value: types.ToolUseBlockDelta{Input: aws.String(`"test"}`)}, + }, + }, + }, + &types.ConverseStreamOutputMemberContentBlockStop{ + Value: types.ContentBlockStopEvent{ContentBlockIndex: aws.Int32(0)}, + }, + &types.ConverseStreamOutputMemberMessageStop{ + Value: types.MessageStopEvent{StopReason: types.StopReasonToolUse}, + }, + } + + stream := newMockStream(events) + resp, err := parseStreamResponse(context.Background(), stream, nil) + + require.NoError(t, err) + assert.Equal(t, "tool_calls", resp.FinishReason) + require.Len(t, resp.ToolCalls, 1) + assert.Equal(t, "call_1", resp.ToolCalls[0].ID) + assert.Equal(t, "search", resp.ToolCalls[0].Name) + assert.Equal(t, map[string]any{"q": "test"}, resp.ToolCalls[0].Arguments) + require.NotNil(t, resp.ToolCalls[0].Function) + assert.Equal(t, "search", resp.ToolCalls[0].Function.Name) + assert.Equal(t, `{"q":"test"}`, resp.ToolCalls[0].Function.Arguments) +} + +func TestParseStreamResponse_TextAndToolCall(t *testing.T) { + events := []types.ConverseStreamOutput{ + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + ContentBlockIndex: aws.Int32(0), + Delta: &types.ContentBlockDeltaMemberText{Value: "Let me search that."}, + }, + }, + &types.ConverseStreamOutputMemberContentBlockStart{ + Value: types.ContentBlockStartEvent{ + ContentBlockIndex: aws.Int32(1), + Start: &types.ContentBlockStartMemberToolUse{ + Value: types.ToolUseBlockStart{ + ToolUseId: aws.String("call_2"), + Name: aws.String("web"), + }, + }, + }, + }, + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + ContentBlockIndex: aws.Int32(1), + Delta: &types.ContentBlockDeltaMemberToolUse{ + Value: types.ToolUseBlockDelta{Input: aws.String(`{"url":"https://example.com"}`)}, + }, + }, + }, + &types.ConverseStreamOutputMemberContentBlockStop{ + Value: types.ContentBlockStopEvent{ContentBlockIndex: aws.Int32(1)}, + }, + &types.ConverseStreamOutputMemberMessageStop{ + Value: types.MessageStopEvent{StopReason: types.StopReasonToolUse}, + }, + } + + var chunks []string + stream := newMockStream(events) + resp, err := parseStreamResponse(context.Background(), stream, func(accumulated string) { + chunks = append(chunks, accumulated) + }) + + require.NoError(t, err) + assert.Equal(t, "Let me search that.", resp.Content) + assert.Equal(t, "tool_calls", resp.FinishReason) + require.Len(t, resp.ToolCalls, 1) + assert.Equal(t, "web", resp.ToolCalls[0].Name) + assert.Equal(t, []string{"Let me search that."}, chunks) +} + +func TestParseStreamResponse_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Use an unbuffered channel with no events so ctx.Done() is the only ready case. + ch := make(chan types.ConverseStreamOutput) + + stream := bedrockruntime.NewConverseStreamEventStream(func(es *bedrockruntime.ConverseStreamEventStream) { + es.Reader = &mockStreamReader{ch: ch} + }) + + _, err := parseStreamResponse(ctx, stream, nil) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestParseStreamResponse_InvalidToolJSON(t *testing.T) { + events := []types.ConverseStreamOutput{ + &types.ConverseStreamOutputMemberContentBlockStart{ + Value: types.ContentBlockStartEvent{ + ContentBlockIndex: aws.Int32(0), + Start: &types.ContentBlockStartMemberToolUse{ + Value: types.ToolUseBlockStart{ + ToolUseId: aws.String("call_bad"), + Name: aws.String("broken"), + }, + }, + }, + }, + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + ContentBlockIndex: aws.Int32(0), + Delta: &types.ContentBlockDeltaMemberToolUse{ + Value: types.ToolUseBlockDelta{Input: aws.String(`{not valid json`)}, + }, + }, + }, + &types.ConverseStreamOutputMemberContentBlockStop{ + Value: types.ContentBlockStopEvent{ContentBlockIndex: aws.Int32(0)}, + }, + &types.ConverseStreamOutputMemberMessageStop{ + Value: types.MessageStopEvent{StopReason: types.StopReasonToolUse}, + }, + } + + stream := newMockStream(events) + resp, err := parseStreamResponse(context.Background(), stream, nil) + + require.NoError(t, err) + require.Len(t, resp.ToolCalls, 1) + assert.Equal(t, map[string]any{"raw": `{not valid json`}, resp.ToolCalls[0].Arguments) + assert.JSONEq(t, `{"raw":"{not valid json"}`, resp.ToolCalls[0].Function.Arguments) +} + +func TestParseStreamResponse_DefaultFinishReason(t *testing.T) { + events := []types.ConverseStreamOutput{ + &types.ConverseStreamOutputMemberContentBlockDelta{ + Value: types.ContentBlockDeltaEvent{ + Delta: &types.ContentBlockDeltaMemberText{Value: "partial"}, + ContentBlockIndex: aws.Int32(0), + }, + }, + } + + stream := newMockStream(events) + resp, err := parseStreamResponse(context.Background(), stream, nil) + + require.NoError(t, err) + assert.Equal(t, "stop", resp.FinishReason) +} + +func TestParseStreamResponse_NilStream(t *testing.T) { + _, err := parseStreamResponse(context.Background(), nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil event stream") +} + +func TestParseStreamResponse_StopReasons(t *testing.T) { + tests := []struct { + reason types.StopReason + expected string + }{ + {types.StopReasonEndTurn, "stop"}, + {types.StopReasonMaxTokens, "length"}, + {types.StopReasonToolUse, "tool_calls"}, + {types.StopReasonStopSequence, "stop"}, + {types.StopReasonContentFiltered, "content_filter"}, + } + + for _, tt := range tests { + t.Run(string(tt.reason), func(t *testing.T) { + events := []types.ConverseStreamOutput{ + &types.ConverseStreamOutputMemberMessageStop{ + Value: types.MessageStopEvent{StopReason: tt.reason}, + }, + } + stream := newMockStream(events) + resp, err := parseStreamResponse(context.Background(), stream, nil) + require.NoError(t, err) + assert.Equal(t, tt.expected, resp.FinishReason) + }) + } +} From f3ef7090c5d40b463cf1730132b770c1039daca9 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Mon, 4 May 2026 08:41:17 +0200 Subject: [PATCH 24/71] feat(agent): stop command --- pkg/agent/agent.go | 10 +++ pkg/agent/agent_command.go | 6 ++ pkg/agent/pipeline_llm.go | 2 +- pkg/agent/steering.go | 23 ++++++ pkg/agent/steering_test.go | 143 +++++++++++++++++++++++++++++++++++ pkg/agent/turn_coord.go | 9 +++ pkg/agent/turn_state.go | 5 +- pkg/commands/builtin.go | 1 + pkg/commands/builtin_test.go | 56 ++++++++++++++ pkg/commands/runtime.go | 7 ++ 10 files changed, 260 insertions(+), 2 deletions(-) diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 84849aece..bb21b7c5e 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -58,6 +58,7 @@ type AgentLoop struct { hookRuntime hookRuntime steering *steeringQueue pendingSkills sync.Map + pendingStops sync.Map mu sync.RWMutex // workerSem limits concurrent turn processing workers. @@ -177,6 +178,10 @@ func (al *AgentLoop) Run(ctx context.Context) error { phase: TurnPhaseSetup, } if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded { + if al.tryHandleStopCommand(ctx, msg, sessionKey) { + continue + } + // Another turn is already active (or reserved) for this session — enqueue if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ Role: "user", @@ -240,6 +245,11 @@ func (al *AgentLoop) Run(ctx context.Context) error { defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID) } + if al.takePendingStop(sessionKey) { + al.activeTurnStates.Delete(sessionKey) + return + } + al.runTurnWithSteering(ctx, m) }(msg) diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index a2ed068d6..ae0293d71 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -274,6 +274,12 @@ func (al *AgentLoop) buildCommandsRuntime( return nil }, } + rt.StopActiveTurn = func() (commands.StopResult, error) { + if opts == nil { + return commands.StopResult{}, fmt.Errorf("process options not available") + } + return al.stopActiveTurnForSession(opts.Dispatch.SessionKey) + } if agent != nil && agent.ContextBuilder != nil { rt.ListSkillNames = agent.ContextBuilder.ListSkillNames } diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index ff242aef7..496fcd7e4 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -292,7 +292,7 @@ func (p *Pipeline) CallLLM( if isNetworkError && retry < maxRetries { backoff := time.Duration(retry+1) * time.Duration(backoffSecs) * time.Second al.emitEvent( - EventKindLLMRetry, + runtimeevents.KindAgentLLMRetry, ts.eventMeta("runTurn", "turn.llm.retry"), LLMRetryPayload{ Attempt: retry + 1, diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index ba171fe5d..7bddbfc31 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -156,6 +156,18 @@ func (sq *steeringQueue) lenScope(scope string) int { return len(sq.queues[normalizeSteeringScope(scope)]) } +func (sq *steeringQueue) clearScope(scope string) int { + sq.mu.Lock() + defer sq.mu.Unlock() + + scope = normalizeSteeringScope(scope) + count := len(sq.queues[scope]) + if count > 0 { + delete(sq.queues, scope) + } + return count +} + // setMode updates the steering mode. func (sq *steeringQueue) setMode(mode SteeringMode) { sq.mu.Lock() @@ -290,6 +302,13 @@ func (al *AgentLoop) pendingSteeringCountForScope(scope string) int { return al.steering.lenScope(scope) } +func (al *AgentLoop) clearSteeringMessagesForScope(scope string) int { + if al.steering == nil { + return 0 + } + return al.steering.clearScope(scope) +} + func (al *AgentLoop) continueWithSteeringMessages( ctx context.Context, agent *AgentInstance, @@ -511,6 +530,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error { "initial_history_length": ts.initialHistoryLength, }) + // Cancel the active provider/tool turn contexts immediately so long-running + // execution stops as soon as possible on the root turn. + _ = ts.requestHardAbort() + // IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns // from adding more messages to the session. This prevents race conditions // where rollback happens while children are still writing. diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 25e06d7a2..1ee1653e9 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -1392,6 +1392,149 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) { } } +func TestAgentLoop_StopCommand_AbortsActiveTurnAndClearsQueuedSteering(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolCallProvider{ + toolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Name: "cancel_tool", + Function: &providers.FunctionCall{ + Name: "cancel_tool", + Arguments: "{}", + }, + Arguments: map[string]any{}, + }, + }, + finalResp: "should not continue", + } + + al := NewAgentLoop(cfg, msgBus, provider) + started := make(chan struct{}) + al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started}) + sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + baseMsg := testInboundMessage(bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + SessionKey: sessionKey, + }) + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "do work", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(start) error = %v", err) + } + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for interruptible tool to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "follow up after cancel", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(sessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up message to enter steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: baseMsg.Context, + Content: "/stop", + SessionKey: sessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + select { + case outbound := <-msgBus.OutboundChan(): + want := "⏹️ Task stopped. \"do work\" was canceled." + if outbound.Content != want { + t.Fatalf("stop reply = %q, want %q", outbound.Content, want) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for /stop reply") + } + + deadline = time.Now().Add(5 * time.Second) + for al.GetActiveTurnBySession(sessionKey) != nil { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for active turn to stop") + } + time.Sleep(10 * time.Millisecond) + } + + if got := al.pendingSteeringCountForScope(sessionKey); got != 0 { + t.Fatalf("expected cleared steering queue, got %d pending message(s)", got) + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected outbound after stop: %q", outbound.Content) + case <-time.After(300 * time.Millisecond): + } + + provider.mu.Lock() + calls := provider.calls + provider.mu.Unlock() + if calls != 1 { + t.Fatalf("expected provider to stop before follow-up turn, got %d calls", calls) + } +} + // capturingMockProvider captures messages sent to Chat for inspection. type capturingMockProvider struct { response string diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index ae6bd8c82..2826e662c 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -26,6 +26,10 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel al.registerActiveTurn(ts) defer al.clearActiveTurn(ts) + if al.takePendingStop(ts.sessionKey) { + _ = ts.requestHardAbort() + } + turnStatus := TurnEndStatusCompleted defer func() { al.emitEvent( @@ -40,6 +44,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel ) }() + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + al.emitEvent( runtimeevents.KindAgentTurnStart, ts.eventMeta("runTurn", "turn.start"), diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 85e7dd3c0..b769ebcd0 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -256,7 +256,10 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop // Bind session store and capture initial history length for rollback logic if agent != nil && agent.Sessions != nil { ts.session = agent.Sessions - ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.Dispatch.SessionKey)) + history := agent.Sessions.GetHistory(opts.Dispatch.SessionKey) + ts.initialHistoryLength = len(history) + ts.restorePointHistory = append([]providers.Message(nil), history...) + ts.restorePointSummary = agent.Sessions.GetSummary(opts.Dispatch.SessionKey) } return ts diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index a7e401bb8..e268812a0 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -8,6 +8,7 @@ func BuiltinDefinitions() []Definition { return []Definition{ startCommand(), helpCommand(), + stopCommand(), showCommand(), listCommand(), useCommand(), diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index efd27fa00..bb9abe360 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -42,6 +42,9 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } + if !strings.Contains(reply, "/stop") { + t.Fatalf("/help reply missing /stop usage, got %q", reply) + } if !strings.Contains(reply, "/use ") { if !strings.Contains(reply, "/use [message]") { t.Fatalf("/help reply missing /use usage, got %q", reply) @@ -49,6 +52,59 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { } } +func TestBuiltinStop_UsesRuntimeStopper(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{ + Stopped: true, + TaskName: "sync the long running job", + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Task stopped. \"sync the long running job\" was canceled." { + t.Fatalf("/stop reply=%q", reply) + } +} + +func TestBuiltinStop_NoActiveTask(t *testing.T) { + rt := &Runtime{ + StopActiveTurn: func() (StopResult, error) { + return StopResult{}, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/stop", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "No active task to stop." { + t.Fatalf("/stop reply=%q, want no-active message", reply) + } +} + func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index c17b7cf1c..b0327c863 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -36,6 +36,12 @@ type ContextStats struct { MessageCount int } +// StopResult describes the outcome of a stop request for the current session. +type StopResult struct { + Stopped bool + TaskName string +} + // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). @@ -55,4 +61,5 @@ type Runtime struct { SwitchChannel func(value string) error ClearHistory func() error ReloadConfig func() error + StopActiveTurn func() (StopResult, error) } From a0245c7b02e3828fab780c6ff5bdea221a291fd6 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Mon, 4 May 2026 08:41:29 +0200 Subject: [PATCH 25/71] feat(agent): stop command --- pkg/agent/agent_stop.go | 103 +++++++++++++++++++++++++++++++++++++++ pkg/commands/cmd_stop.go | 52 ++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 pkg/agent/agent_stop.go create mode 100644 pkg/commands/cmd_stop.go diff --git a/pkg/agent/agent_stop.go b/pkg/agent/agent_stop.go new file mode 100644 index 000000000..2f93c5684 --- /dev/null +++ b/pkg/agent/agent_stop.go @@ -0,0 +1,103 @@ +package agent + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/commands" +) + +func (al *AgentLoop) tryHandleStopCommand( + ctx context.Context, + msg bus.InboundMessage, + sessionKey string, +) bool { + cmdName, ok := commands.CommandName(msg.Content) + if !ok || cmdName != "stop" { + return false + } + + result, err := al.stopActiveTurnForSession(sessionKey) + reply := commands.FormatStopReply(result) + if err != nil { + reply = "Failed to stop task: " + err.Error() + } + + if al.channelManager != nil { + al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID) + } + al.resetMessageToolRound(sessionKey) + al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, sessionKey, reply) + return true +} + +func (al *AgentLoop) stopActiveTurnForSession(sessionKey string) (commands.StopResult, error) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return commands.StopResult{}, fmt.Errorf("session key is required") + } + + result := commands.StopResult{} + cleared := al.clearSteeringMessagesForScope(sessionKey) + al.clearPendingSkills(sessionKey) + + ts := al.getActiveTurnState(sessionKey) + if ts == nil { + result.Stopped = cleared > 0 + return result, nil + } + + snap := ts.snapshot() + result.TaskName = snap.UserMessage + + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + al.markPendingStop(sessionKey) + result.Stopped = true + return result, nil + } + + if err := al.HardAbort(sessionKey); err != nil { + if al.getActiveTurnState(sessionKey) == nil { + result.Stopped = cleared > 0 + return result, nil + } + return commands.StopResult{}, err + } + + result.Stopped = true + return result, nil +} + +func (al *AgentLoop) markPendingStop(sessionKey string) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return + } + al.pendingStops.Store(sessionKey, struct{}{}) +} + +func (al *AgentLoop) takePendingStop(sessionKey string) bool { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return false + } + _, ok := al.pendingStops.LoadAndDelete(sessionKey) + return ok +} + +func (al *AgentLoop) resetMessageToolRound(sessionKey string) { + if strings.TrimSpace(sessionKey) == "" { + return + } + if registry := al.GetRegistry(); registry != nil { + if agent := registry.GetDefaultAgent(); agent != nil { + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + } + } +} diff --git a/pkg/commands/cmd_stop.go b/pkg/commands/cmd_stop.go new file mode 100644 index 000000000..147688bdc --- /dev/null +++ b/pkg/commands/cmd_stop.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func stopCommand() Definition { + return Definition{ + Name: "stop", + Description: "Stop the current task", + Usage: "/stop", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.StopActiveTurn == nil { + return req.Reply(unavailableMsg) + } + + result, err := rt.StopActiveTurn() + if err != nil { + return req.Reply("Failed to stop task: " + err.Error()) + } + + return req.Reply(FormatStopReply(result)) + }, + } +} + +// FormatStopReply renders a user-facing reply for a stop request. +func FormatStopReply(result StopResult) string { + if !result.Stopped { + return "No active task to stop." + } + + taskName := compactStopTaskName(result.TaskName) + if taskName == "" { + return "Task stopped. Current task was canceled." + } + + return fmt.Sprintf("Task stopped. %q was canceled.", taskName) +} + +func compactStopTaskName(taskName string) string { + taskName = strings.Join(strings.Fields(strings.TrimSpace(taskName)), " ") + if taskName == "" { + return "" + } + if len(taskName) > 80 { + return taskName[:77] + "..." + } + return taskName +} From 7a1f5fe8b9d86804922584f3246899dffb51ffd4 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Mon, 4 May 2026 09:06:39 +0200 Subject: [PATCH 26/71] fix test --- pkg/agent/steering_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 1ee1653e9..eb8874122 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -1501,7 +1501,7 @@ func TestAgentLoop_StopCommand_AbortsActiveTurnAndClearsQueuedSteering(t *testin select { case outbound := <-msgBus.OutboundChan(): - want := "⏹️ Task stopped. \"do work\" was canceled." + want := "Task stopped. \"do work\" was canceled." if outbound.Content != want { t.Fatalf("stop reply = %q, want %q", outbound.Content, want) } From d63430ab33ef1f0cd30c29431f42127715ba10bb Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Mon, 4 May 2026 13:10:02 +0200 Subject: [PATCH 27/71] fix(agent): don't arm pending stop when /stop targets idle session --- pkg/agent/agent_stop.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/agent/agent_stop.go b/pkg/agent/agent_stop.go index 2f93c5684..54cd51477 100644 --- a/pkg/agent/agent_stop.go +++ b/pkg/agent/agent_stop.go @@ -20,6 +20,22 @@ func (al *AgentLoop) tryHandleStopCommand( } result, err := al.stopActiveTurnForSession(sessionKey) + + // This function is only called when loaded=true (another turn already + // claimed this session). If stopActiveTurnForSession found a pending + // placeholder but didn't stop it, that placeholder belongs to the other + // message's worker which hasn't started yet — arm a pending stop so the + // worker will bail when it checks before running. + if err == nil && !result.Stopped { + if ts := al.getActiveTurnState(sessionKey); ts != nil { + snap := ts.snapshot() + if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { + al.markPendingStop(sessionKey) + result.Stopped = true + } + } + } + reply := commands.FormatStopReply(result) if err != nil { reply = "Failed to stop task: " + err.Error() @@ -53,8 +69,11 @@ func (al *AgentLoop) stopActiveTurnForSession(sessionKey string) (commands.StopR result.TaskName = snap.UserMessage if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) { - al.markPendingStop(sessionKey) - result.Stopped = true + // A pending placeholder means this session is either idle (our own + // placeholder from the /stop command) or another message is queued but + // hasn't started yet. In both cases, we don't arm a pending stop here; + // the caller (tryHandleStopCommand) handles the "another message queued" + // case explicitly, since it knows loaded=true. return result, nil } From a7e52e8a25341027fa1b03f866dc89194a71a9f9 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Tue, 5 May 2026 19:24:15 +0200 Subject: [PATCH 28/71] fix(agent): drain scoped follow-up queue when pending stop skips turn startup --- pkg/agent/agent.go | 13 +++ pkg/agent/agent_steering.go | 46 ++++++--- pkg/agent/steering_test.go | 185 ++++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 15 deletions(-) diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index bb21b7c5e..97ee4fe7d 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -247,6 +247,19 @@ func (al *AgentLoop) Run(ctx context.Context) error { if al.takePendingStop(sessionKey) { al.activeTurnStates.Delete(sessionKey) + target := &continuationTarget{ + SessionKey: sessionKey, + Channel: m.Channel, + ChatID: m.ChatID, + } + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + al.maybePublishError(ctx, m.Channel, m.ChatID, sessionKey, continueErr) + return + } + if continued != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued) + } return } diff --git a/pkg/agent/agent_steering.go b/pkg/agent/agent_steering.go index c674bcafa..9b136e7cd 100644 --- a/pkg/agent/agent_steering.go +++ b/pkg/agent/agent_steering.go @@ -44,11 +44,36 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb return } - // Drain steering queue using existing Continue mechanism + continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target) + if continueErr != nil { + logger.WarnCF("agent", "Failed to continue queued steering", + map[string]any{ + "channel": target.Channel, + "chat_id": target.ChatID, + "error": continueErr.Error(), + }) + } else if continued != "" { + finalResponse = continued + } + + // Publish final response + if finalResponse != "" { + al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) + } +} + +func (al *AgentLoop) drainQueuedSteeringContinuations( + ctx context.Context, + target *continuationTarget, +) (string, error) { + if target == nil { + return "", nil + } + + finalResponse := "" for al.pendingSteeringCountForScope(target.SessionKey) > 0 { - // Check for context cancellation between iterations - if ctx.Err() != nil { - return + if err := ctx.Err(); err != nil { + return finalResponse, err } logger.InfoCF("agent", "Continuing queued steering after turn end", @@ -61,13 +86,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { - logger.WarnCF("agent", "Failed to continue queued steering", - map[string]any{ - "channel": target.Channel, - "chat_id": target.ChatID, - "error": continueErr.Error(), - }) - break + return finalResponse, continueErr } if continued == "" { break @@ -75,10 +94,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb finalResponse = continued } - // Publish final response - if finalResponse != "" { - al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse) - } + return finalResponse, nil } func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) { diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index eb8874122..813013649 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -840,6 +840,191 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { } } +func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + MaxParallelTurns: 1, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &lateSteeringProvider{ + firstCallStarted: make(chan struct{}), + releaseFirstCall: make(chan struct{}), + } + al := NewAgentLoop(cfg, msgBus, provider) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + defer func() { + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + }() + + blockerSessionKey := session.BuildOpaqueSessionKey("agent:main:test:blocker") + targetSessionKey := session.BuildOpaqueSessionKey("agent:main:test:target") + blockerCtx := bus.InboundContext{ + Channel: "test", + ChatID: "blocker-chat", + ChatType: "direct", + SenderID: "user1", + } + targetCtx := bus.InboundContext{ + Channel: "test", + ChatID: "target-chat", + ChatType: "direct", + SenderID: "user1", + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: blockerCtx, + Content: "block worker pool", + SessionKey: blockerSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(blocker) error = %v", err) + } + + select { + case <-provider.firstCallStarted: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for blocker turn to start") + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "skip this turn", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(target start) error = %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for { + ts := al.getActiveTurnState(targetSessionKey) + if ts != nil && strings.HasPrefix(ts.turnID, pendingTurnPrefix) { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for pending placeholder") + } + time.Sleep(10 * time.Millisecond) + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "/stop", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(/stop) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + stopSeen := false + for !stopSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "Task stopped. Current task was canceled." { + stopSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for /stop reply") + } + } + } + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Context: targetCtx, + Content: "run this instead", + SessionKey: targetSessionKey, + }); err != nil { + t.Fatalf("PublishInbound(follow-up) error = %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + for al.pendingSteeringCountForScope(targetSessionKey) == 0 { + if time.Now().After(deadline) { + t.Fatal("timeout waiting for follow-up to enter scoped steering queue") + } + time.Sleep(10 * time.Millisecond) + } + + close(provider.releaseFirstCall) + + deadline = time.Now().Add(5 * time.Second) + followUpSeen := false + for !followUpSeen { + select { + case outbound := <-msgBus.OutboundChan(): + if outbound.ChatID == "target-chat" && outbound.Content == "continued response" { + followUpSeen = true + } + case <-time.After(10 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("timeout waiting for queued follow-up continuation") + } + } + } + + deadline = time.Now().Add(2 * time.Second) + for { + if al.GetActiveTurnBySession(targetSessionKey) == nil && + al.pendingSteeringCountForScope(targetSessionKey) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("timeout waiting for target session to go idle") + } + time.Sleep(10 * time.Millisecond) + } + + provider.mu.Lock() + calls := provider.calls + secondMessages := append([]providers.Message(nil), provider.secondCallMessages...) + provider.mu.Unlock() + + if calls != 2 { + t.Fatalf("expected 2 provider calls (blocker + continuation), got %d", calls) + } + + foundFollowUp := false + for _, msg := range secondMessages { + if msg.Role == "user" && msg.Content == "run this instead" { + foundFollowUp = true + } + if msg.Role == "user" && msg.Content == "skip this turn" { + t.Fatalf("unexpected canceled message in continuation context: %q", msg.Content) + } + } + if !foundFollowUp { + t.Fatal("expected queued follow-up to be processed after pending stop") + } +} + func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { From 0977f59feeeac74d8c2ba0bd9a97ad7958bfa62f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 14:27:14 +0800 Subject: [PATCH 29/71] build(deps): bump github.com/larksuite/oapi-sdk-go/v3 (#2736) Bumps [github.com/larksuite/oapi-sdk-go/v3](https://github.com/larksuite/oapi-sdk-go) from 3.5.4 to 3.6.1. - [Release notes](https://github.com/larksuite/oapi-sdk-go/releases) - [Changelog](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/changelog.md) - [Commits](https://github.com/larksuite/oapi-sdk-go/compare/v3.5.4...v3.6.1) --- updated-dependencies: - dependency-name: github.com/larksuite/oapi-sdk-go/v3 dependency-version: 3.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f49cfd320..f52e328cf 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 - github.com/larksuite/oapi-sdk-go/v3 v3.5.4 + github.com/larksuite/oapi-sdk-go/v3 v3.6.1 github.com/mdp/qrterminal/v3 v3.2.1 github.com/minio/selfupdate v0.6.0 github.com/modelcontextprotocol/go-sdk v1.5.0 diff --git a/go.sum b/go.sum index 083f59d1b..d43e48f5b 100644 --- a/go.sum +++ b/go.sum @@ -177,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.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0= -github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ= +github.com/larksuite/oapi-sdk-go/v3 v3.6.1/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= From e3a05bd36d235dfa0ddcc6379a545bbc2e98b8c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 14:32:00 +0800 Subject: [PATCH 30/71] build(deps): bump @tailwindcss/vite from 4.2.2 to 4.2.4 in /web/frontend (#2734) Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.2.2 to 4.2.4. - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-vite) --- updated-dependencies: - dependency-name: "@tailwindcss/vite" dependency-version: 4.2.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 273 ++++++++++++++++++------------------ 2 files changed, 135 insertions(+), 140 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index ca7c56cef..bf3e7921b 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -19,7 +19,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@tabler/icons-react": "^3.40.0", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.0", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 232bb7541..78639de19 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^3.40.0 version: 3.41.1(react@19.2.5) '@tailwindcss/vite': - specifier: ^4.2.2 - 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)) + specifier: ^4.2.4 + version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) '@tanstack/react-query': specifier: ^5.99.0 version: 5.99.0(react@19.2.5) @@ -98,13 +98,13 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.1(jiti@2.7.0)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.4) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.167.9(@tanstack/react-router@1.169.2(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)) + version: 1.167.9(@tanstack/react-router@1.169.2(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.7.0)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.3) @@ -119,22 +119,22 @@ 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.59.1(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.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^6.0.1 - 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)) + version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) eslint: specifier: ^10.2.1 - version: 10.2.1(jiti@2.6.1) + version: 10.2.1(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) + version: 10.1.8(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@10.2.1(jiti@2.6.1)) + version: 7.1.1(eslint@10.2.1(jiti@2.7.0)) eslint-plugin-react-refresh: specifier: ^0.5.2 - version: 0.5.2(eslint@10.2.1(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.1(jiti@2.7.0)) globals: specifier: ^17.5.0 version: 17.5.0 @@ -149,10 +149,10 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.59.1 - version: 8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) vite: 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) + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) packages: @@ -1459,69 +1459,69 @@ packages: '@tabler/icons@3.41.1': resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1532,20 +1532,20 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} engines: {node: '>= 20'} '@tailwindcss/typography@0.5.19': @@ -1553,8 +1553,8 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -2204,8 +2204,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} entities@6.0.1: @@ -2743,8 +2743,8 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true jose@6.2.2: @@ -3709,14 +3709,11 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} - tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tiny-invariant@1.3.3: @@ -4357,9 +4354,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.7.0))': dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4380,9 +4377,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.7.0))': optionalDependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} @@ -5353,78 +5350,78 @@ snapshots: '@tabler/icons@3.41.1': {} - '@tailwindcss/node@4.2.2': + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 + enhanced-resolve: 5.21.0 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.2 + tailwindcss: 4.2.4 - '@tailwindcss/oxide-android-arm64@4.2.2': + '@tailwindcss/oxide-android-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.2': + '@tailwindcss/oxide-darwin-arm64@4.2.4': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.2': + '@tailwindcss/oxide-darwin-x64@4.2.4': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.2': + '@tailwindcss/oxide-freebsd-x64@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.2': + '@tailwindcss/oxide-linux-x64-musl@4.2.4': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.2': + '@tailwindcss/oxide-wasm32-wasi@4.2.4': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': optional: true - '@tailwindcss/oxide@4.2.2': + '@tailwindcss/oxide@4.2.4': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': dependencies: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.4 - '@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))': + '@tailwindcss/vite@4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) '@tanstack/history@1.161.6': {} @@ -5497,7 +5494,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(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))': + '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.169.2(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.7.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5514,7 +5511,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.169.2(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) + vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5609,15 +5606,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.59.1(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.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.58.2 - '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.58.2 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5625,15 +5622,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.1 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5641,14 +5638,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.1 '@typescript-eslint/types': 8.59.1 '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.1 debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5689,25 +5686,25 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.1 '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5747,24 +5744,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.58.2 '@typescript-eslint/types': 8.58.2 '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.59.1 '@typescript-eslint/types': 8.59.1 '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5781,10 +5778,10 @@ snapshots: '@ungap/structured-clone@1.3.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))': + '@vitejs/plugin-react@6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@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.7.0)(tsx@4.21.0) accepts@2.0.0: dependencies: @@ -6081,10 +6078,10 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.20.1: + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.2 + tapable: 2.3.3 entities@6.0.1: {} @@ -6139,24 +6136,24 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) - eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.7.0)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.7.0)): dependencies: - eslint: 10.2.1(jiti@2.6.1) + eslint: 10.2.1(jiti@2.7.0) eslint-scope@9.1.2: dependencies: @@ -6169,9 +6166,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.1(jiti@2.6.1): + eslint@10.2.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.5.5 @@ -6202,7 +6199,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -6693,7 +6690,7 @@ snapshots: javascript-natural-sort@0.7.1: {} - jiti@2.6.1: {} + jiti@2.7.0: {} jose@6.2.2: {} @@ -7899,11 +7896,9 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss@4.2.2: {} - tailwindcss@4.2.4: {} - tapable@2.3.2: {} + tapable@2.3.3: {} tiny-invariant@1.3.3: {} @@ -7972,13 +7967,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(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.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.2.1(jiti@2.6.1) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.2.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8109,7 +8104,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@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.7.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8120,7 +8115,7 @@ snapshots: '@types/node': 25.6.0 esbuild: 0.27.4 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 tsx: 4.21.0 void-elements@3.1.0: {} From 4d3070e849620c543bf8c2b06dc0e6552908801f Mon Sep 17 00:00:00 2001 From: openapphub Date: Wed, 6 May 2026 14:44:36 +0800 Subject: [PATCH 31/71] =?UTF-8?q?fix(web):=20=E5=85=BC=E5=AE=B9=20HTTP=20?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=A4=8D=E5=88=B6=E6=8C=89=E9=92=AE=20(#2712?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: openapphub <175949671+openapphub@users.noreply.github.com> --- .../src/components/chat/assistant-message.tsx | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 07a3c0abc..157ca636f 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -56,11 +56,38 @@ export function AssistantMessage({ const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" - const handleCopy = () => { - navigator.clipboard.writeText(content).then(() => { + const handleCopy = async () => { + const markCopied = () => { setIsCopied(true) setTimeout(() => setIsCopied(false), 2000) - }) + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(content) + markCopied() + return + } + } catch { + // HTTP 或受限环境下可能不支持 Clipboard API,继续走降级方案 + } + + const textArea = document.createElement("textarea") + textArea.value = content + textArea.setAttribute("readonly", "") + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + document.body.appendChild(textArea) + textArea.select() + + try { + const copied = document.execCommand("copy") + if (copied) { + markCopied() + } + } finally { + document.body.removeChild(textArea) + } } const collapsedLabel = isThought From 81a050555d8f6b960e8f2c1df69e1daf75c2b856 Mon Sep 17 00:00:00 2001 From: LC Date: Wed, 6 May 2026 16:06:49 +0800 Subject: [PATCH 32/71] feat(provider,web,asr): enhance model management with explicit provider metadata (#2701) * feat(provider,web): enhance model management with provider options * fix(asr): enhance compatibility for ElevenLabs transcription model * fix(provider,web): align provider availability predicates and add flow gating * fix(web,asr): preserve legacy elevenlabs transcription configs * fix(provider,web,asr): normalize elevenlabs configs and gate default chat models * fix: tighten provider catalog and elevenlabs compatibility --- pkg/audio/asr/README.md | 7 +- pkg/audio/asr/README.zh.md | 7 +- pkg/audio/asr/asr.go | 27 +- pkg/audio/asr/asr_test.go | 15 + pkg/audio/asr/elevenlabs_transcriber.go | 9 +- pkg/audio/asr/elevenlabs_transcriber_test.go | 85 +- pkg/providers/factory_provider.go | 33 +- pkg/providers/factory_provider_test.go | 132 ++- pkg/providers/model_ref.go | 25 +- pkg/providers/model_ref_test.go | 44 + pkg/providers/provider_catalog.go | 181 +++ web/backend/api/gateway.go | 3 + web/backend/api/gateway_test.go | 38 + web/backend/api/model_status.go | 65 +- web/backend/api/models.go | 234 +++- web/backend/api/models_test.go | 1040 ++++++++++++++++- web/frontend/src/api/models.ts | 12 + .../src/components/models/add-model-sheet.tsx | 160 ++- .../components/models/edit-model-sheet.tsx | 172 ++- .../src/components/models/model-card.tsx | 8 +- .../src/components/models/models-page.tsx | 59 +- .../src/components/models/provider-icon.tsx | 2 + .../src/components/models/provider-label.ts | 96 ++ web/frontend/src/hooks/use-chat-models.ts | 50 +- web/frontend/src/i18n/locales/en.json | 15 +- web/frontend/src/i18n/locales/zh.json | 15 +- 26 files changed, 2341 insertions(+), 193 deletions(-) create mode 100644 pkg/providers/provider_catalog.go diff --git a/pkg/audio/asr/README.md b/pkg/audio/asr/README.md index 0477276dd..99d2a8c90 100644 --- a/pkg/audio/asr/README.md +++ b/pkg/audio/asr/README.md @@ -82,7 +82,8 @@ Notes: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw currently supports three main ASR routes: | Route | Example models | Behavior | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. | +| ElevenLabs ASR | `provider: elevenlabs`, `model: scribe_v1` | Uses the ElevenLabs transcription API. | | Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. | | Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. | @@ -142,7 +143,7 @@ If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first. 1. **Preferred path**: resolve `voice.model_name` against `model_list`. 2. If that resolved model is: - - `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber. + - an `elevenlabs` provider model, PicoClaw uses the ElevenLabs transcriber. - an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber. - an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`. 3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries. diff --git a/pkg/audio/asr/README.zh.md b/pkg/audio/asr/README.zh.md index 104116080..670698cb8 100644 --- a/pkg/audio/asr/README.zh.md +++ b/pkg/audio/asr/README.zh.md @@ -82,7 +82,8 @@ model_list: "model_list": [ { "model_name": "elevenlabs-asr", - "model": "elevenlabs/scribe_v1" + "provider": "elevenlabs", + "model": "scribe_v1" } ] } @@ -130,7 +131,7 @@ PicoClaw 目前主要支持三种 ASR 路径: | 路径 | 示例模型 | 行为说明 | | --- | --- | --- | -| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | +| ElevenLabs ASR | `provider: elevenlabs`,`model: scribe_v1` | 使用 ElevenLabs 的语音转录接口。 | | Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 | | 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 | @@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径: 1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。 2. 如果找到的模型属于以下类型: - - `elevenlabs/...`,则使用 ElevenLabs transcriber。 + - `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。 - OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。 - 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。 3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。 diff --git a/pkg/audio/asr/asr.go b/pkg/audio/asr/asr.go index 1482f40bb..a7c93e578 100644 --- a/pkg/audio/asr/asr.go +++ b/pkg/audio/asr/asr.go @@ -8,6 +8,12 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +const elevenLabsSupportedModelID = "scribe_v1" + +func ElevenLabsSupportedModelID() string { + return elevenLabsSupportedModelID +} + type Transcriber interface { Name() string Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) @@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string { return "" } +func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool { + if modelCfg == nil || modelCfg.APIKey() == "" { + return false + } + + protocol, _ := providers.ExtractProtocol(modelCfg) + return protocol == "elevenlabs" +} + func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber { if modelCfg == nil { return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) @@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe return nil } - protocol, _ := providers.ExtractProtocol(modelCfg) - if protocol == "elevenlabs" && modelCfg.APIKey() != "" { - return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase) + if isElevenLabsTranscriptionModel(modelCfg) { + _, modelID := providers.ExtractProtocol(modelCfg) + return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID) } if modelID := whisperModelID(modelCfg); modelID != "" { return NewWhisperTranscriber(modelCfg) diff --git a/pkg/audio/asr/asr_test.go b/pkg/audio/asr/asr_test.go index 0970d69f4..f877b1198 100644 --- a/pkg/audio/asr/asr_test.go +++ b/pkg/audio/asr/asr_test.go @@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) { }, wantName: "elevenlabs", }, + { + name: "explicit elevenlabs provider selects elevenlabs transcriber", + cfg: &config.Config{ + Voice: config.VoiceConfig{ModelName: "my-asr-model"}, + ModelList: []*config.ModelConfig{ + { + ModelName: "my-asr-model", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + }, + }, + wantName: "elevenlabs", + }, { name: "voice model name alias selects whisper transcriber for groq", cfg: &config.Config{ diff --git a/pkg/audio/asr/elevenlabs_transcriber.go b/pkg/audio/asr/elevenlabs_transcriber.go index 452b9512d..a89d62848 100644 --- a/pkg/audio/asr/elevenlabs_transcriber.go +++ b/pkg/audio/asr/elevenlabs_transcriber.go @@ -20,19 +20,24 @@ import ( type ElevenLabsTranscriber struct { apiKey string apiBase string + modelID string httpClient *http.Client } -func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber { +func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber { logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""}) if apiBase == "" { apiBase = "https://api.elevenlabs.io" } + if modelID == "" || modelID != ElevenLabsSupportedModelID() { + modelID = ElevenLabsSupportedModelID() + } return &ElevenLabsTranscriber{ apiKey: apiKey, apiBase: apiBase, + modelID: modelID, httpClient: &http.Client{ Timeout: 120 * time.Second, }, @@ -74,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st return nil, fmt.Errorf("failed to copy file content: %w", err) } - if err = writer.WriteField("model_id", "scribe_v1"); err != nil { + if err = writer.WriteField("model_id", t.modelID); err != nil { return nil, fmt.Errorf("failed to write model_id field: %w", err) } diff --git a/pkg/audio/asr/elevenlabs_transcriber_test.go b/pkg/audio/asr/elevenlabs_transcriber_test.go index fa80110be..bbc827578 100644 --- a/pkg/audio/asr/elevenlabs_transcriber_test.go +++ b/pkg/audio/asr/elevenlabs_transcriber_test.go @@ -3,10 +3,14 @@ package asr import ( "context" "encoding/json" + "io" + "mime" + "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -14,7 +18,7 @@ import ( var _ Transcriber = (*ElevenLabsTranscriber)(nil) func TestElevenLabsTranscriberName(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") if got := tr.Name(); got != "elevenlabs" { t.Errorf("Name() = %q, want %q", got, "elevenlabs") } @@ -35,6 +39,35 @@ func TestElevenLabsTranscribe(t *testing.T) { if r.Header.Get("Xi-Api-Key") != "sk_test" { t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key")) } + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1") + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(TranscriptionResponse{ Text: "hello from elevenlabs", @@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") tr.apiBase = srv.URL resp, err := tr.Transcribe(context.Background(), audioPath) @@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) { })) defer srv.Close() - tr := NewElevenLabsTranscriber("sk_bad", "") + tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1") tr.apiBase = srv.URL _, err := tr.Transcribe(context.Background(), audioPath) @@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) { }) t.Run("missing file", func(t *testing.T) { - tr := NewElevenLabsTranscriber("sk_test", "") + tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1") _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) if err == nil { t.Fatal("expected error for missing file, got nil") } }) + + t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content-type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(r.Body, params["boundary"]) + var gotModelID string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("NextPart() error = %v", err) + } + if part.FormName() != "model_id" { + continue + } + body, err := io.ReadAll(part) + if err != nil { + t.Fatalf("ReadAll(part) error = %v", err) + } + gotModelID = strings.TrimSpace(string(body)) + } + if gotModelID != "scribe_v1" { + t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"}) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model") + tr.apiBase = srv.URL + + if _, err := tr.Transcribe(context.Background(), audioPath); err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + }) } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index a59e2de25..aa99d6d38 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -110,19 +110,7 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { if provider := strings.TrimSpace(cfg.Provider); provider != "" { return NormalizeProvider(provider), model } - if model == "" { - return "", "" - } - - protocol, rest, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - protocol = strings.TrimSpace(protocol) - if protocol == "" { - return "", strings.TrimSpace(rest) - } - return NormalizeProvider(protocol), strings.TrimSpace(rest) + return SplitModelProviderAndID(model, "openai") } // ResolveAPIBase returns the configured API base, or the protocol default when @@ -154,6 +142,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } protocol, modelID := ExtractProtocol(cfg) + authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod)) userAgent := cfg.UserAgent if userAgent == "" { @@ -163,7 +152,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { provider, err := createCodexAuthProvider() if err != nil { return nil, "", err @@ -320,7 +309,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return finalizeProviderFromConfig(provider, modelID, cfg) case "anthropic": - if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { + if authMethod == "oauth" || authMethod == "token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { @@ -431,7 +420,7 @@ func finalizeProviderFromConfig( } func isEmptyAPIKeyAllowed(protocol string) bool { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) return ok && meta.emptyAPIKeyAllowed } @@ -451,9 +440,19 @@ func DefaultAPIBaseForProtocol(protocol string) string { // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - meta, ok := protocolMetaByName[protocol] + meta, ok := protocolMetaForName(protocol) if !ok { return "" } return meta.defaultAPIBase } + +func protocolMetaForName(protocol string) (protocolMeta, bool) { + if meta, ok := protocolMetaByName[protocol]; ok { + return meta, true + } + if meta, ok := attachedModelProviderMetaByName[protocol]; ok { + return meta.protocolMeta, true + } + return protocolMeta{}, false +} diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 3d3c30ce0..eb9b3d600 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -101,6 +102,12 @@ func TestExtractProtocol(t *testing.T) { wantProtocol: "", wantModelID: "gpt-4o", }, + { + name: "unknown prefix falls back to openai", + config: &config.ModelConfig{Model: "meta-llama/Llama-3.1-8B-Instruct"}, + wantProtocol: "openai", + wantModelID: "meta-llama/Llama-3.1-8B-Instruct", + }, { name: "nil config", wantProtocol: "", @@ -605,6 +612,41 @@ func TestCreateProviderFromConfig_CodexCLI(t *testing.T) { } } +func TestCreateProviderFromConfig_OpenAIMixedCaseAuthMethodUsesOAuthBranch(t *testing.T) { + origGetCredential := getCredential + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want %q", provider, "openai") + } + return &auth.AuthCredential{ + AccessToken: "test-token", + AccountID: "acct-test", + Provider: "openai", + AuthMethod: "oauth", + }, nil + } + t.Cleanup(func() { + getCredential = origGetCredential + }) + + cfg := &config.ModelConfig{ + ModelName: "test-openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "OAuth", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "gpt-5.4" { + t.Errorf("modelID = %q, want %q", modelID, "gpt-5.4") + } +} + func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-no-key", @@ -619,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) { func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { cfg := &config.ModelConfig{ - ModelName: "test-unknown", - Model: "unknown-protocol/model", + ModelName: "test-unknown-provider", + Provider: "unknown-protocol", + Model: "model", } cfg.SetAPIKey("test-key") @@ -630,6 +673,26 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) { } } +func TestCreateProviderFromConfig_UnknownModelPrefixDefaultsToOpenAI(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-unknown-model-prefix", + Model: "meta-llama/Llama-3.1-8B-Instruct", + APIBase: "https://api.example.com/v1", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("modelID = %q, want full model ID", modelID) + } +} + func TestCreateProviderFromConfig_NilConfig(t *testing.T) { _, _, err := CreateProviderFromConfig(nil) if err == nil { @@ -889,6 +952,71 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) { } } +func TestModelProviderOptions(t *testing.T) { + options := ModelProviderOptions() + if len(options) == 0 { + t.Fatal("ModelProviderOptions() returned no options") + } + + seen := make(map[string]ModelProviderOption, len(options)) + for _, option := range options { + seen[option.ID] = option + } + + if _, ok := seen["openai"]; !ok { + t.Fatal("openai option missing") + } + if option, ok := seen["openai"]; ok && !option.CreateAllowed { + t.Fatal("openai should be creatable") + } + if option, ok := seen["lmstudio"]; !ok { + t.Fatal("lmstudio option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty API keys") + } + if option, ok := seen["anthropic"]; !ok { + t.Fatal("anthropic option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := seen["azure"]; !ok { + t.Fatal("azure option missing") + } + if option, ok := seen["bedrock"]; !ok { + t.Fatal("bedrock option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should be creatable and defer credential/build errors to runtime") + } + if option, ok := seen["elevenlabs"]; !ok { + t.Fatal("elevenlabs option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be ASR-only and therefore not allowed as a default chat model") + } + } + if option, ok := seen["antigravity"]; !ok { + t.Fatal("antigravity option missing") + } else { + if !option.CreateAllowed { + t.Fatal("antigravity should be creatable") + } + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + if option, ok := seen["github-copilot"]; !ok { + t.Fatal("github-copilot option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } +} + func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) { var requestBody map[string]any diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go index be9f63bc6..48e3fb4cb 100644 --- a/pkg/providers/model_ref.go +++ b/pkg/providers/model_ref.go @@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef { return nil } - if idx := strings.Index(raw, "/"); idx > 0 { - provider := NormalizeProvider(raw[:idx]) - model := strings.TrimSpace(raw[idx+1:]) - if model == "" { - return nil - } - return &ModelRef{Provider: provider, Model: model} + provider, model := SplitModelProviderAndID(raw, defaultProvider) + if model == "" { + return nil } - return &ModelRef{ - Provider: NormalizeProvider(defaultProvider), - Model: raw, + Provider: provider, + Model: model, } } @@ -53,6 +48,8 @@ func NormalizeProvider(provider string) string { return "zhipu" case "google": return "gemini" + case "google-antigravity": + return "antigravity" case "alibaba-coding", "qwen-coding": return "coding-plan" case "alibaba-coding-anthropic": @@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string { return "qwen-intl" case "dashscope-us": return "qwen-us" + case "azure-openai": + return "azure" + case "claudecli": + return "claude-cli" + case "codexcli": + return "codex-cli" + case "copilot": + return "github-copilot" } return p diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go index 040c511ba..9a164bf48 100644 --- a/pkg/providers/model_ref_test.go +++ b/pkg/providers/model_ref_test.go @@ -72,7 +72,12 @@ func TestNormalizeProvider(t *testing.T) { {"claude", "anthropic"}, {"glm", "zhipu"}, {"google", "gemini"}, + {"google-antigravity", "antigravity"}, {"groq", "groq"}, + {"azure-openai", "azure"}, + {"claudecli", "claude-cli"}, + {"codexcli", "codex-cli"}, + {"copilot", "github-copilot"}, // Alibaba Coding Plan aliases {"alibaba-coding", "coding-plan"}, {"qwen-coding", "coding-plan"}, @@ -131,3 +136,42 @@ func TestParseModelRef_DefaultProviderNormalization(t *testing.T) { t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider) } } + +func TestParseModelRef_UnknownPrefixFallsBackToDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "openai" { + t.Fatalf("provider = %q, want openai", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_UnknownPrefixPreservesEmptyDefaultProvider(t *testing.T) { + ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "" { + t.Fatalf("provider = %q, want empty", ref.Provider) + } + if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" { + t.Fatalf("model = %q, want full original model ID", ref.Model) + } +} + +func TestParseModelRef_KnownNonSelectableProvider(t *testing.T) { + ref := ParseModelRef("bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", "openai") + if ref == nil { + t.Fatal("expected non-nil ref") + } + if ref.Provider != "bedrock" { + t.Fatalf("provider = %q, want bedrock", ref.Provider) + } + if ref.Model != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want preserved bedrock model ID", ref.Model) + } +} diff --git a/pkg/providers/provider_catalog.go b/pkg/providers/provider_catalog.go new file mode 100644 index 000000000..a9178cb81 --- /dev/null +++ b/pkg/providers/provider_catalog.go @@ -0,0 +1,181 @@ +package providers + +import ( + "sort" + "strings" +) + +// ModelProviderOption describes a canonical provider entry exposed to the Web UI. +type ModelProviderOption struct { + ID string `json:"id"` + DefaultAPIBase string `json:"default_api_base"` + EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"` + CreateAllowed bool `json:"create_allowed"` + DefaultModelAllowed bool `json:"default_model_allowed"` + DefaultAuthMethod string `json:"default_auth_method,omitempty"` + AuthMethodLocked bool `json:"auth_method_locked,omitempty"` +} + +type attachedModelProviderMeta struct { + protocolMeta + createAllowed bool + defaultModelAllowed bool + defaultAuthMethod string + authMethodLocked bool +} + +// attachedModelProviderMetaByName augments protocolMetaByName for provider +// families that are implemented in CreateProviderFromConfig but intentionally +// kept out of the core HTTP metadata map because they have special auth/runtime +// semantics. +var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{ + "azure": {createAllowed: true, defaultModelAllowed: true}, + "anthropic": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "anthropic-messages": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"}, + createAllowed: true, + defaultModelAllowed: true, + }, + "bedrock": {createAllowed: true, defaultModelAllowed: true}, + "antigravity": { + createAllowed: true, + defaultModelAllowed: true, + defaultAuthMethod: "oauth", + authMethodLocked: true, + }, + "claude-cli": {createAllowed: true, defaultModelAllowed: true}, + "codex-cli": {createAllowed: true, defaultModelAllowed: true}, + "github-copilot": { + protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, + createAllowed: true, + defaultModelAllowed: true, + }, + // ElevenLabs is intentionally exposed only as an ASR-capable provider. It + // belongs in the shared model catalog because ASR is configured via + // model_list, but it must not be selectable as the default chat model. + "elevenlabs": { + protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"}, + createAllowed: true, + defaultModelAllowed: false, + }, +} + +// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI. +func ModelProviderOptions() []ModelProviderOption { + optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName)) + for provider := range protocolMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: DefaultAPIBaseForProtocol(provider), + EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider), + CreateAllowed: true, + DefaultModelAllowed: true, + } + } + for provider, meta := range attachedModelProviderMetaByName { + if NormalizeProvider(provider) != provider { + continue + } + optionsByID[provider] = ModelProviderOption{ + ID: provider, + DefaultAPIBase: meta.defaultAPIBase, + EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed, + CreateAllowed: meta.createAllowed, + DefaultModelAllowed: meta.defaultModelAllowed, + DefaultAuthMethod: meta.defaultAuthMethod, + AuthMethodLocked: meta.authMethodLocked, + } + } + + options := make([]ModelProviderOption, 0, len(optionsByID)) + for _, option := range optionsByID { + options = append(options, option) + } + sort.Slice(options, func(i, j int) bool { + return options[i].ID < options[j].ID + }) + return options +} + +// IsSupportedModelProvider reports whether provider resolves to a provider ID +// returned by ModelProviderOptions. +func IsSupportedModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + _, ok := attachedModelProviderMetaByName[normalized] + return ok +} + +// IsCreatableModelProvider reports whether provider can be selected for a new +// model entry from the Web UI. +func IsCreatableModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.createAllowed +} + +// IsDefaultModelProvider reports whether provider can be used as the default +// chat model. Some providers such as ASR-only entries are intentionally +// exposed in model_list management but cannot drive the gateway default model. +func IsDefaultModelProvider(provider string) bool { + normalized := NormalizeProvider(provider) + if normalized == "" { + return false + } + if _, ok := protocolMetaByName[normalized]; ok { + return true + } + meta, ok := attachedModelProviderMetaByName[normalized] + return ok && meta.defaultModelAllowed +} + +// SplitModelProviderAndID separates a legacy "provider/model" string into its +// effective provider and canonical model ID. Unknown prefixes are treated as +// part of the model ID and fall back to defaultProvider. +func SplitModelProviderAndID(model, defaultProvider string) (provider, modelID string) { + model = strings.TrimSpace(model) + if model == "" { + return "", "" + } + + provider, modelID = splitKnownProviderModel(model) + if provider != "" || modelID != "" { + return provider, modelID + } + + return NormalizeProvider(defaultProvider), model +} + +func splitKnownProviderModel(model string) (provider, modelID string) { + provider, modelID, found := strings.Cut(strings.TrimSpace(model), "/") + if !found { + return "", "" + } + provider = strings.TrimSpace(provider) + modelID = strings.TrimSpace(modelID) + if provider == "" { + return "", modelID + } + if !IsSupportedModelProvider(provider) { + return "", "" + } + return NormalizeProvider(provider), modelID +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 67b055236..45f7e6912 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { if modelCfg == nil { return false, fmt.Sprintf("default model %q is invalid", modelName), nil } + if !defaultModelAllowedForModelConfig(modelCfg) { + return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil + } if !hasModelConfiguration(modelCfg) { return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 1d9352972..f383089a6 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -357,6 +357,44 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) { } } +func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatal("gatewayStartReady() ready = true, want false") + } + if reason != `default model "elevenlabs-asr" is not usable for chat` { + t.Fatalf( + "gatewayStartReady() reason = %q, want %q", + reason, + `default model "elevenlabs-asr" is not usable for chat`, + ) + } +} + func TestLooksLikeGatewayCommandLine(t *testing.T) { cases := []struct { name string diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index d262cf124..302231d80 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "os/exec" "strconv" "strings" "sync" @@ -47,6 +48,7 @@ var ( probeTCPServiceFunc = probeTCPService probeOllamaModelFunc = probeOllamaModel probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel + probeCommandAvailableFunc = probeCommandAvailable modelProbeNowFunc = time.Now modelProbeState = newModelProbeCacheState() ) @@ -83,17 +85,23 @@ func (s *modelProbeCacheState) resetForTest() { } func hasModelConfiguration(m *config.ModelConfig) bool { + protocol := modelProtocol(m) authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) apiKey := strings.TrimSpace(m.APIKey()) if authMethod == "oauth" || authMethod == "token" { - if provider, ok := oauthProviderForModel(m); ok { - cred, err := oauthGetCredential(provider) - if err != nil || cred == nil { - return false - } - return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured } + } + + if authMethod == "" && providerUsesImplicitOAuth(protocol) { + if configured, checked := hasStoredOAuthCredential(m); checked { + return configured + } + } + + if providerUsesAmbientCredentials(protocol) { return true } @@ -104,6 +112,40 @@ func hasModelConfiguration(m *config.ModelConfig) bool { return apiKey != "" } +func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) { + provider, ok := oauthProviderForModel(m) + if !ok { + return false, false + } + cred, err := oauthGetCredential(provider) + if err != nil || cred == nil { + return false, true + } + return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true +} + +func providerUsesImplicitOAuth(protocol string) bool { + switch protocol { + case "antigravity", "google-antigravity": + return true + default: + return false + } +} + +func providerUsesAmbientCredentials(protocol string) bool { + switch protocol { + case "bedrock": + // Bedrock relies on the AWS SDK credential chain instead of an explicit + // API key stored in ModelConfig. We cannot reliably preflight every AWS + // credential source here, so avoid misclassifying valid environments as + // "unconfigured" and defer concrete credential failures to runtime. + return true + default: + return false + } +} + func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary { if !hasModelConfiguration(m) { return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured} @@ -180,8 +222,10 @@ func runLocalModelProbe(m *config.ModelConfig) bool { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) - case "claude-cli", "claudecli", "codex-cli", "codexcli": - return true + case "claude-cli", "claudecli": + return probeCommandAvailableFunc("claude") + case "codex-cli", "codexcli": + return probeCommandAvailableFunc("codex") default: if hasLocalAPIBase(apiBase) { return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) @@ -190,6 +234,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool { } } +func probeCommandAvailable(command string) bool { + _, err := exec.LookPath(command) + return err == nil +} + func modelProbeCacheKey(m *config.ModelConfig) string { protocol, modelID := splitModel(m) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 61eb235cb..8a66918f9 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" @@ -45,11 +46,184 @@ type modelResponse struct { ExtraBody map[string]any `json:"extra_body,omitempty"` CustomHeaders map[string]string `json:"custom_headers,omitempty"` // Meta - Enabled bool `json:"enabled"` - Available bool `json:"available"` - Status string `json:"status"` - IsDefault bool `json:"is_default"` - IsVirtual bool `json:"is_virtual"` + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Status string `json:"status"` + IsDefault bool `json:"is_default"` + IsVirtual bool `json:"is_virtual"` + DefaultModelAllowed bool `json:"default_model_allowed"` +} + +func normalizeStoredModelConfig(mc *config.ModelConfig) bool { + if mc == nil { + return false + } + + changed := false + model := strings.TrimSpace(mc.Model) + if model != mc.Model { + mc.Model = model + changed = true + } + provider := strings.TrimSpace(mc.Provider) + if provider != mc.Provider { + mc.Provider = provider + changed = true + } + authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if authMethod != mc.AuthMethod { + mc.AuthMethod = authMethod + changed = true + } + + if provider != "" { + normalizedProvider := providers.NormalizeProvider(provider) + if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider { + mc.Provider = normalizedProvider + changed = true + } + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut( + model, + "/", + ); found && + providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" && strippedModel != mc.Model { + mc.Model = strippedModel + changed = true + } + } + if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + mc.Model = asr.ElevenLabsSupportedModelID() + changed = true + } + } + return changed + } + + effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai") + if effectiveProvider == "" { + return changed + } + if mc.Provider != effectiveProvider { + mc.Provider = effectiveProvider + changed = true + } + if mc.Model != modelID { + mc.Model = modelID + changed = true + } + return changed +} + +func normalizeIncomingModelConfig(mc *config.ModelConfig) { + if mc == nil { + return + } + + mc.Model = strings.TrimSpace(mc.Model) + mc.Provider = strings.TrimSpace(mc.Provider) + mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod)) + if mc.Provider == "" { + mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai") + } else { + mc.Provider = providers.NormalizeProvider(mc.Provider) + if mc.Provider == "elevenlabs" { + if _, strippedModel, found := strings.Cut(mc.Model, "/"); found { + strippedModel = strings.TrimSpace(strippedModel) + if strippedModel != "" { + mc.Model = strippedModel + } + } + } + } + if mc.Provider == "antigravity" && mc.AuthMethod == "" { + mc.AuthMethod = "oauth" + } +} + +func createAllowedForProvider(provider string) bool { + normalized := providers.NormalizeProvider(provider) + switch normalized { + case "bedrock": + // Bedrock currently authenticates through the AWS SDK credential chain + // (env vars, shared profiles, IAM roles, etc.), and this Web layer does + // not yet have a reliable preflight check for those credential sources. + // Keep it creatable in the catalog and let provider construction/runtime + // return the concrete AWS error when the environment is incomplete. + return true + case "claude-cli", "codex-cli": + return cliProviderCreateAllowedFromCurrentStatus(normalized) + default: + return providers.IsCreatableModelProvider(normalized) + } +} + +// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing +// local model status pipeline so provider catalog gating follows the same CLI +// executable probe used by launcher readiness. +func cliProviderCreateAllowedFromCurrentStatus(provider string) bool { + status := modelConfigurationStatus(&config.ModelConfig{ + Provider: provider, + Model: provider, + }) + return status.Available +} + +func modelProviderOptionsForResponse() []providers.ModelProviderOption { + options := providers.ModelProviderOptions() + for i := range options { + options[i].CreateAllowed = createAllowedForProvider(options[i].ID) + } + return options +} + +func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool { + provider, _ := providers.ExtractProtocol(mc) + return providers.IsDefaultModelProvider(provider) +} + +func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error { + if mc == nil { + return fmt.Errorf("model config is required") + } + if err := mc.Validate(); err != nil { + return err + } + if strings.TrimSpace(mc.Provider) == "" { + return fmt.Errorf("provider is required") + } + if !providers.IsSupportedModelProvider(mc.Provider) { + return fmt.Errorf("provider %q is not supported", mc.Provider) + } + if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() { + return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID()) + } + if !createAllowedForProvider(mc.Provider) { + if existing == nil { + return fmt.Errorf("provider %q is not available for new models", mc.Provider) + } + existingProvider, _ := providers.ExtractProtocol(existing) + if providers.NormalizeProvider(existingProvider) != mc.Provider { + return fmt.Errorf("provider %q is not available for selection", mc.Provider) + } + } + return nil +} + +func normalizeStoredModelProviders(cfg *config.Config) bool { + if cfg == nil { + return false + } + + changed := false + for _, model := range cfg.ModelList { + if normalizeStoredModelConfig(model) { + changed = true + } + } + return changed } // handleListModels returns all model_list entries with masked API keys. @@ -62,6 +236,10 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { return } + // Normalize legacy provider/model storage in memory so GET can round-trip + // through the current API shape without mutating the on-disk config. + normalizeStoredModelProviders(cfg) + defaultModel := cfg.Agents.Defaults.GetModelName() modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList)) @@ -101,14 +279,16 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { Status: modelStatuses[i].Status, IsDefault: m.ModelName == defaultModel, IsVirtual: m.IsVirtual(), + DefaultModelAllowed: defaultModelAllowedForModelConfig(m), }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "models": models, - "total": len(models), - "default_model": defaultModel, + "models": models, + "total": len(models), + "default_model": defaultModel, + "provider_options": modelProviderOptionsForResponse(), }) } @@ -134,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { + normalizeIncomingModelConfig(&mc.ModelConfig) + + if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil { http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) return } @@ -150,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { } cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig) + normalizeStoredModelProviders(cfg) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) @@ -200,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { return } - if err = mc.Validate(); err != nil { - http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) - return - } - cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) @@ -253,9 +431,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { // This keeps provider-omitted updates backward-compatible even when an // older client edits the visible model ID. if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" { - existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model) incomingModel := strings.TrimSpace(mc.Model) + existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx]) if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" { if incomingModel == existingModelID { mc.Model = existingRawModel @@ -272,7 +450,20 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } } + normalizeIncomingModelConfig(&mc.ModelConfig) + if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName && + !defaultModelAllowedForModelConfig(&mc.ModelConfig) { + // Allow users to recover from legacy/invalid defaults by saving the model + // and clearing the default chat model reference in the same write. + cfg.Agents.Defaults.ModelName = "" + } + cfg.ModelList[idx] = &mc.ModelConfig + normalizeStoredModelProviders(cfg) logger.Debugf("update model config: %#v", mc.ModelConfig) @@ -372,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest) return } + for _, m := range cfg.ModelList { + if m.ModelName == req.ModelName { + if !defaultModelAllowedForModelConfig(m) { + http.Error( + w, + fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName), + http.StatusBadRequest, + ) + return + } + break + } + } cfg.Agents.Defaults.ModelName = req.ModelName diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index dd5ff6a54..0b1f04848 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) func resetModelProbeHooks(t *testing.T) { @@ -20,17 +21,46 @@ func resetModelProbeHooks(t *testing.T) { origTCPProbe := probeTCPServiceFunc origOllamaProbe := probeOllamaModelFunc origOpenAIProbe := probeOpenAICompatibleModelFunc + origCommandProbe := probeCommandAvailableFunc origNow := modelProbeNowFunc resetModelProbeCache() t.Cleanup(func() { probeTCPServiceFunc = origTCPProbe probeOllamaModelFunc = origOllamaProbe probeOpenAICompatibleModelFunc = origOpenAIProbe + probeCommandAvailableFunc = origCommandProbe modelProbeNowFunc = origNow resetModelProbeCache() }) } +func addModelAndLoadLatest(t *testing.T, configPath string, body string) *config.ModelConfig { + t.Helper() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(cfg.ModelList) == 0 { + t.Fatal("model_list should contain the newly added model") + } + + return cfg.ModelList[len(cfg.ModelList)-1] +} + func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -94,7 +124,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing }, } cfg.Agents.Defaults.ModelName = "openai-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -113,7 +144,8 @@ func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } @@ -181,14 +213,91 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) AuthMethod: "oauth", }} cfg.Agents.Defaults.ModelName = "claude-oauth" - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } - if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + if setCredentialErr := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ AccessToken: "anthropic-token", Provider: oauthProviderAnthropic, AuthMethod: "oauth", + }); setCredentialErr != nil { + t.Fatalf("SetCredential() error = %v", setCredentialErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatalf("oauth model available = false, want true with stored credential") + } +} + +func TestHasModelConfiguration_OAuthWithoutMappedCredentialFallsBackToAPIKey(t *testing.T) { + noKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + } + if hasModelConfiguration(noKey) { + t.Fatal("oauth model without credential mapping and api key should be unconfigured") + } + + withKey := &config.ModelConfig{ + Provider: "gemini", + Model: "gemini-2.5-flash", + AuthMethod: "oauth", + APIKeys: config.SimpleSecureStrings("gemini-key"), + } + if !hasModelConfiguration(withKey) { + t.Fatal("oauth model without credential mapping should fall back to api key configuration") + } +} + +func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "gemini-flash", + Provider: "antigravity", + Model: "gemini-3-flash", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{ + AccessToken: "antigravity-token", + Provider: oauthProviderGoogleAntigravity, + AuthMethod: "oauth", }); err != nil { t.Fatalf("SetCredential() error = %v", err) } @@ -208,14 +317,158 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) } if !resp.Models[0].Available { - t.Fatalf("oauth model available = false, want true with stored credential") + t.Fatal("antigravity model available = false, want true with stored credential even without auth_method") + } +} + +func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Available { + t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials") + } + if resp.Models[0].Status != modelStatusAvailable { + t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable) + } +} + +func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + switch command { + case "claude": + return false + case "codex": + return true + default: + return false + } + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "claude-cli-model", + Provider: "claude-cli", + Model: "claude-cli", + }, + { + ModelName: "codex-cli-model", + Provider: "codex-cli", + Model: "codex-cli", + }, + } + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + + modelsByName := make(map[string]modelResponse, len(resp.Models)) + for _, model := range resp.Models { + modelsByName[model.ModelName] = model + } + if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable { + t.Fatalf( + "claude-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + false, + modelStatusUnreachable, + ) + } + if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable { + t.Fatalf( + "codex-cli status = (%t, %q), want (%t, %q)", + model.Available, + model.Status, + true, + modelStatusAvailable, + ) + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if option, ok := optionsByID["claude-cli"]; !ok { + t.Fatal("claude-cli provider option missing") + } else if option.CreateAllowed { + t.Fatal("claude-cli should not be creatable when the claude command is missing") + } + if option, ok := optionsByID["codex-cli"]; !ok { + t.Fatal("codex-cli provider option missing") + } else if !option.CreateAllowed { + t.Fatal("codex-cli should be creatable when the codex command is available") } } @@ -321,8 +574,8 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) } if len(resp.Models) != 1 { t.Fatalf("len(models) = %d, want 1", len(resp.Models)) @@ -508,6 +761,223 @@ func TestHandleAddModel_PersistsProvider(t *testing.T) { } } +func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bad-provider", + "provider":"not-supported", + "model":"gpt-4o-mini" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "not-supported" is not supported`) { + t.Fatalf("body = %q, want unsupported provider error", rec.Body.String()) + } +} + +func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-sonnet-4-20250514-v1:0" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + added := cfg.ModelList[len(cfg.ModelList)-1] + if got := added.Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want bedrock model ID", got) + } +} + +func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if len(updated.ModelList) != 2 { + t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList)) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_NormalizesExplicitElevenLabsUnsupportedModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"new-model", + "provider":"openai", + "model":"gpt-4o-mini", + "api_key":"sk-new-model-key" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1") + } +} + +func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + probeCommandAvailableFunc = func(command string) bool { + return false + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"claude-cli-model", + "provider":"claude-cli", + "model":"claude-cli" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "claude-cli" is not available for new models`) { + t.Fatalf("body = %q, want missing cli command error", rec.Body.String()) + } +} + +func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"gemini-flash", + "provider":"antigravity", + "model":"gemini-3-flash" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + +func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + added := addModelAndLoadLatest(t, configPath, `{ + "model_name":"openai-oauth", + "provider":"openai", + "model":"gpt-5.4", + "auth_method":"OAuth" + }`) + if got := added.AuthMethod; got != "oauth" { + t.Fatalf("auth_method = %q, want %q", got, "oauth") + } +} + func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -845,7 +1315,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { Provider: "openrouter", Model: "openrouter/auto", }} - if err := config.SaveConfig(configPath, cfg); err != nil { + err = config.SaveConfig(configPath, cfg) + if err != nil { t.Fatalf("SaveConfig() error = %v", err) } @@ -864,7 +1335,8 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { var resp struct { Models []modelResponse `json:"models"` } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + err = json.Unmarshal(rec.Body.Bytes(), &resp) + if err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(resp.Models) != 1 { @@ -878,6 +1350,55 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) { } } +func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := resp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if resp.Models[0].DefaultModelAllowed { + t.Fatal("elevenlabs ASR model should not be allowed as the default chat model") + } +} + func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -940,11 +1461,230 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } +} + +func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Model: "elevenlabs/scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_RoundTripsExplicitLegacyElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v2", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recList := httptest.NewRecorder() + reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(recList, reqList) + + if recList.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String()) + } + + var listResp struct { + Models []modelResponse `json:"models"` + } + if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(listResp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(listResp.Models)) + } + if got := listResp.Models[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := listResp.Models[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q after GET normalization", got, "scribe_v1") + } + + recUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + reqUpdate.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(recUpdate, reqUpdate) + + if recUpdate.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "elevenlabs" { + t.Fatalf("provider = %q, want %q", got, "elevenlabs") + } + if got := updated.ModelList[0].Model; got != "scribe_v1" { + t.Fatalf("model = %q, want %q", got, "scribe_v1") + } + if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" { + t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io") + } +} + +func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }} + cfg.Agents.Defaults.ModelName = "elevenlabs-asr" + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v1", + "api_base":"https://api.elevenlabs.io" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.Agents.Defaults.ModelName; got != "" { + t.Fatalf("default model = %q, want cleared default", got) + } +} + +func TestHandleAddModel_RejectsUnsupportedElevenLabsModelID(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{ + "model_name":"elevenlabs-asr", + "provider":"elevenlabs", + "model":"scribe_v2" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `provider "elevenlabs" only supports model "scribe_v1"`) { + t.Fatalf("body = %q, want elevenlabs model validation error", rec.Body.String()) } } @@ -984,11 +1724,125 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModel if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := updated.ModelList[0].Provider; got != "" { - t.Fatalf("provider = %q, want empty", got) + if got := updated.ModelList[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") } - if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" { - t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5") + if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5") + } +} + +func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "legacy-openrouter", + Model: "openrouter/openai/gpt-5.4", + }} + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if unmarshalErr := json.Unmarshal(rec.Body.Bytes(), &resp); unmarshalErr != nil { + t.Fatalf("Unmarshal() error = %v", unmarshalErr) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "openrouter" { + t.Fatalf("provider = %q, want %q", got, "openrouter") + } + if got := resp.Models[0].Model; got != "openai/gpt-5.4" { + t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4") + } + + optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions)) + for _, option := range resp.ProviderOptions { + optionsByID[option.ID] = option + } + if len(optionsByID) == 0 { + t.Fatal("provider_options should not be empty") + } + if option, ok := optionsByID["openai"]; !ok { + t.Fatal("openai provider option missing") + } else if option.DefaultAPIBase != "https://api.openai.com/v1" { + t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1") + } + if option, ok := optionsByID["anthropic"]; !ok { + t.Fatal("anthropic provider option missing") + } else if option.DefaultAPIBase != "https://api.anthropic.com/v1" { + t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1") + } + if _, ok := optionsByID["azure"]; !ok { + t.Fatal("azure provider option missing") + } + if option, ok := optionsByID["github-copilot"]; !ok { + t.Fatal("github-copilot provider option missing") + } else if option.DefaultAPIBase != "localhost:4321" { + t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321") + } + if option, ok := optionsByID["elevenlabs"]; !ok { + t.Fatal("elevenlabs provider option missing") + } else { + if option.DefaultAPIBase != "https://api.elevenlabs.io" { + t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io") + } + if option.DefaultModelAllowed { + t.Fatal("elevenlabs should be marked as not allowed for default chat model selection") + } + } + if option, ok := optionsByID["lmstudio"]; !ok { + t.Fatal("lmstudio provider option missing") + } else if !option.EmptyAPIKeyAllowed { + t.Fatal("lmstudio should allow empty api keys") + } + if option, ok := optionsByID["bedrock"]; !ok { + t.Fatal("bedrock provider option missing") + } else if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime") + } + if option, ok := optionsByID["antigravity"]; !ok { + t.Fatal("antigravity provider option missing") + } else { + if option.DefaultAuthMethod != "oauth" { + t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth") + } + if !option.AuthMethodLocked { + t.Fatal("antigravity auth method should be locked") + } + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "" { + t.Fatalf("persisted provider = %q, want unchanged empty provider", got) + } + if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" { + t.Fatalf("persisted model = %q, want unchanged legacy model", got) } } @@ -1036,6 +1890,115 @@ func TestHandleListModels_ReturnsProviderField(t *testing.T) { } } +func TestHandleListModels_PreservesKnownProviderInCatalog(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + ProviderOptions []providers.ModelProviderOption `json:"provider_options"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if got := resp.Models[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" { + t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0") + } + foundBedrock := false + for _, option := range resp.ProviderOptions { + if option.ID == "bedrock" { + foundBedrock = true + if !option.CreateAllowed { + t.Fatal("bedrock should stay creatable in provider_options") + } + } + } + if !foundBedrock { + t.Fatal("bedrock should be included in provider_options for compatibility") + } +} + +func TestHandleUpdateModel_AllowsExistingBedrockProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "bedrock-claude", + Provider: "bedrock", + Model: "us.anthropic.claude-sonnet-4-20250514-v1:0", + APIBase: "us-west-2", + }} + if saveErr := config.SaveConfig(configPath, cfg); saveErr != nil { + t.Fatalf("SaveConfig() error = %v", saveErr) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{ + "model_name":"bedrock-claude", + "provider":"bedrock", + "model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0", + "api_base":"us-east-1" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := updated.ModelList[0].Provider; got != "bedrock" { + t.Fatalf("provider = %q, want %q", got, "bedrock") + } + if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" { + t.Fatalf("model = %q, want updated bedrock model", got) + } + if got := updated.ModelList[0].APIBase; got != "us-east-1" { + t.Fatalf("api_base = %q, want %q", got, "us-east-1") + } +} + func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -1147,6 +2110,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) { } } +func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []*config.ModelConfig{ + { + ModelName: "elevenlabs-asr", + Provider: "elevenlabs", + Model: "scribe_v1", + APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"), + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{ + "model_name": "elevenlabs-asr" + }`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") { + t.Fatalf("body = %q, want default chat model rejection", rec.Body.String()) + } +} + func TestMaskAPIKey(t *testing.T) { tests := []struct { name string diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index 926bf8a0a..5bb275fde 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -27,12 +27,24 @@ export interface ModelInfo { status: "available" | "unconfigured" | "unreachable" is_default: boolean is_virtual: boolean + default_model_allowed?: boolean +} + +export interface ModelProviderOption { + id: string + default_api_base: string + empty_api_key_allowed: boolean + create_allowed: boolean + default_model_allowed: boolean + default_auth_method?: string + auth_method_locked?: boolean } interface ModelsListResponse { models: ModelInfo[] total: number default_model: string + provider_options: ModelProviderOption[] } interface ModelActionResponse { diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 376c42263..e0f51596a 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -1,8 +1,12 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { addModel, setDefaultModel } from "@/api/models" +import { + type ModelProviderOption, + addModel, + setDefaultModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -13,6 +17,13 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -25,6 +36,15 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { + findProviderOption, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + getProviderLabel, + getSortedProviderOptions, + isProviderAuthMethodLocked, +} from "./provider-label" + interface AddForm { modelName: string provider: string @@ -46,7 +66,7 @@ interface AddForm { const EMPTY_ADD_FORM: AddForm = { modelName: "", - provider: "", + provider: "openai", model: "", apiBase: "", apiKey: "", @@ -68,6 +88,7 @@ interface AddModelSheetProps { onClose: () => void onSaved: () => void existingModelNames: string[] + providerOptions: ModelProviderOption[] } export function AddModelSheet({ @@ -75,6 +96,7 @@ export function AddModelSheet({ onClose, onSaved, existingModelNames, + providerOptions, }: AddModelSheetProps) { const { t } = useTranslation() const [form, setForm] = useState(EMPTY_ADD_FORM) @@ -88,6 +110,37 @@ export function AddModelSheet({ form.apiKey, t("models.field.apiKeyPlaceholder"), ) + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const creatableProviderOptions = useMemo( + () => sortedProviderOptions.filter((option) => option.create_allowed), + [sortedProviderOptions], + ) + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const isOAuth = effectiveAuthMethod === "oauth" + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault @@ -108,6 +161,9 @@ export function AddModelSheet({ } else if (existingModelNames.some((name) => name.trim() === modelName)) { errors.modelName = t("models.add.errorDuplicateModelName") } + if (!selectedProviderOption) { + errors.provider = t("models.field.providerInvalid") + } if (!form.model.trim()) errors.model = t("models.add.errorRequired") setFieldErrors(errors) return Object.keys(errors).length === 0 @@ -122,22 +178,47 @@ export function AddModelSheet({ } } + const setProvider = (value: string) => { + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + if (fieldErrors.provider) { + setFieldErrors((prev) => ({ ...prev, provider: undefined })) + } + } + const handleSave = async () => { if (!validate()) return setSaving(true) setServerError("") try { const modelName = form.modelName.trim() - const provider = form.provider.trim() const modelId = form.model.trim() await addModel({ model_name: modelName, - provider: provider || undefined, + provider: form.provider.trim(), model: modelId, api_base: form.apiBase.trim() || undefined, api_key: form.apiKey.trim() || undefined, proxy: form.proxy.trim() || undefined, - auth_method: form.authMethod.trim() || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod.trim() || undefined, connect_mode: form.connectMode.trim() || undefined, workspace: form.workspace.trim() || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -208,12 +289,29 @@ export function AddModelSheet({ - + - - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} - /> - + {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + + )} - + @@ -269,12 +378,17 @@ export function AddModelSheet({ diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index d0810e6d6..82d3cf97f 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -1,8 +1,13 @@ import { IconLoader2 } from "@tabler/icons-react" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + setDefaultModel, + updateModel, +} from "@/api/models" import { ConfigChangeNotice } from "@/components/config-change-notice" import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { @@ -13,6 +18,13 @@ import { } from "@/components/shared-form" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { Sheet, SheetContent, @@ -25,6 +37,15 @@ import { Textarea } from "@/components/ui/textarea" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +import { + findProviderOption, + getProviderDefaultAPIBase, + getProviderDefaultAuthMethod, + getProviderLabel, + getSortedProviderOptions, + isProviderAuthMethodLocked, +} from "./provider-label" + interface EditForm { provider: string modelId: string @@ -45,6 +66,7 @@ interface EditForm { interface EditModelSheetProps { model: ModelInfo | null + providerOptions: ModelProviderOption[] open: boolean onClose: () => void onSaved: () => void @@ -76,6 +98,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm { export function EditModelSheet({ model, + providerOptions, open, onClose, onSaved, @@ -102,26 +125,99 @@ export function EditModelSheet({ const [setAsDefault, setSetAsDefault] = useState(false) const [error, setError] = useState("") const initialForm = model ? buildInitialEditForm(model) : null + const sortedProviderOptions = useMemo( + () => getSortedProviderOptions(providerOptions), + [providerOptions], + ) + const currentProviderID = model + ? (findProviderOption(model.provider, providerOptions)?.id ?? + model.provider?.trim().toLowerCase() ?? + "") + : "" + const selectedProviderOption = findProviderOption( + form.provider, + providerOptions, + ) + const authMethodLocked = isProviderAuthMethodLocked( + form.provider, + providerOptions, + ) + const defaultAuthMethod = getProviderDefaultAuthMethod( + form.provider, + providerOptions, + ) + const effectiveAuthMethod = ( + authMethodLocked ? defaultAuthMethod : form.authMethod + ) + .trim() + .toLowerCase() + const providerError = selectedProviderOption + ? "" + : t("models.field.providerInvalid") + const defaultModelAllowed = + selectedProviderOption?.default_model_allowed !== false + const willClearDefaultOnSave = + model?.is_default === true && defaultModelAllowed === false + const apiBasePlaceholder = + getProviderDefaultAPIBase(form.provider, providerOptions) || + "https://api.example.com/v1" const isDirty = model != null && (JSON.stringify(form) !== JSON.stringify(initialForm) || setAsDefault !== model.is_default) useEffect(() => { - if (model) { - setForm(buildInitialEditForm(model)) - setSetAsDefault(model.is_default) - setError("") + if (model) { + const initialForm = buildInitialEditForm(model) + const option = findProviderOption(initialForm.provider, providerOptions) + if (option?.auth_method_locked && !initialForm.authMethod) { + initialForm.authMethod = option.default_auth_method ?? "" } - }, [model]) + setForm(initialForm) + setSetAsDefault(model.is_default && model.default_model_allowed !== false) + setError("") + } + }, [model, providerOptions]) const setField = (key: keyof EditForm) => - (e: React.ChangeEvent) => + (e: React.ChangeEvent) => { + if (error) { + setError("") + } setForm((f) => ({ ...f, [key]: e.target.value })) + } + + const setProvider = (value: string) => { + if (error) { + setError("") + } + setForm((f) => { + const previousOption = findProviderOption(f.provider, providerOptions) + const nextOption = findProviderOption(value, providerOptions) + let authMethod = f.authMethod + if (nextOption?.auth_method_locked) { + authMethod = nextOption.default_auth_method ?? "" + } else if ( + previousOption?.auth_method_locked && + f.authMethod === (previousOption.default_auth_method ?? "") + ) { + authMethod = "" + } + return { ...f, provider: value, authMethod } + }) + const nextOption = findProviderOption(value, providerOptions) + if (nextOption?.default_model_allowed === false) { + setSetAsDefault(false) + } + } const handleSave = async () => { if (!model) return + if (!selectedProviderOption) { + setError(providerError) + return + } if (!form.modelId.trim()) { setError(t("models.add.errorRequired")) return @@ -136,7 +232,9 @@ export function EditModelSheet({ api_base: form.apiBase || undefined, api_key: form.apiKey || undefined, proxy: form.proxy || undefined, - auth_method: form.authMethod || undefined, + auth_method: authMethodLocked + ? defaultAuthMethod || undefined + : form.authMethod || undefined, connect_mode: form.connectMode || undefined, workspace: form.workspace || undefined, rpm: form.rpm ? Number(form.rpm) : undefined, @@ -172,7 +270,7 @@ export function EditModelSheet({ } } - const isOAuth = model?.auth_method === "oauth" + const isOAuth = effectiveAuthMethod === "oauth" const hasSavedAPIKey = Boolean(model?.api_key) const apiKeyPlaceholder = hasSavedAPIKey ? maskedSecretPlaceholder( @@ -201,12 +299,36 @@ export function EditModelSheet({ - + @@ -267,12 +396,17 @@ export function EditModelSheet({ diff --git a/web/frontend/src/components/models/model-card.tsx b/web/frontend/src/components/models/model-card.tsx index 44730bb57..e53fcdeca 100644 --- a/web/frontend/src/components/models/model-card.tsx +++ b/web/frontend/src/components/models/model-card.tsx @@ -36,7 +36,10 @@ export function ModelCard({ const status = model.status const statusLabel = t(`models.status.${status}`) const canSetDefault = - model.available && !model.is_default && !model.is_virtual + model.available && + !model.is_default && + !model.is_virtual && + model.default_model_allowed !== false const setDefaultLabel = t("models.action.setDefault") const setDefaultDisabledReason = (() => { @@ -45,6 +48,9 @@ export function ModelCard({ return t("models.action.setDefaultDisabled.unavailable") if (model.is_default) return t("models.action.setDefaultDisabled.isDefault") if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual") + if (model.default_model_allowed === false) { + return t("models.action.setDefaultDisabled.unsupportedProvider") + } return setDefaultLabel })() diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 152c47585..df372b6b1 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" -import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" +import { + type ModelInfo, + type ModelProviderOption, + getModels, + setDefaultModel, +} from "@/api/models" import { PageHeader } from "@/components/page-header" import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" @@ -12,41 +17,13 @@ import { refreshGatewayState } from "@/store/gateway" import { AddModelSheet } from "./add-model-sheet" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" -import { getProviderKey, getProviderLabel } from "./provider-label" +import { + PROVIDER_PRIORITY, + getProviderKey, + getProviderLabel, +} from "./provider-label" import { ProviderSection } from "./provider-section" -const PROVIDER_PRIORITY: Record = { - volcengine: 0, - openai: 1, - gemini: 2, - anthropic: 3, - zhipu: 4, - deepseek: 5, - openrouter: 6, - "qwen-portal": 7, - "qwen-intl": 8, - moonshot: 9, - groq: 10, - "github-copilot": 11, - antigravity: 12, - nvidia: 13, - cerebras: 14, - shengsuanyun: 15, - venice: 16, - vivgrid: 17, - minimax: 18, - longcat: 19, - modelscope: 20, - mistral: 21, - avian: 22, - azure: 23, - ollama: 24, - vllm: 25, - lmstudio: 26, - zai: 27, - mimo: 28, -} - interface ProviderGroup { key: string label: string @@ -58,6 +35,9 @@ interface ProviderGroup { export function ModelsPage() { const { t } = useTranslation() const [models, setModels] = useState([]) + const [providerOptions, setProviderOptions] = useState( + [], + ) const [loading, setLoading] = useState(true) const [fetchError, setFetchError] = useState("") @@ -67,6 +47,7 @@ export function ModelsPage() { const [settingDefaultIndex, setSettingDefaultIndex] = useState( null, ) + const addDisabled = loading || providerOptions.length === 0 const fetchModels = useCallback(async () => { try { @@ -79,6 +60,7 @@ export function ModelsPage() { return a.model_name.localeCompare(b.model_name) }) setModels(sorted) + setProviderOptions(data.provider_options ?? []) setFetchError("") } catch (e) { setFetchError(e instanceof Error ? e.message : t("models.loadError")) @@ -160,7 +142,12 @@ export function ModelsPage() {
- @@ -213,6 +200,7 @@ export function ModelsPage() { setEditingModel(null)} onSaved={fetchModels} @@ -220,6 +208,7 @@ export function ModelsPage() { setAddOpen(false)} onSaved={fetchModels} existingModelNames={models.map((model) => model.model_name)} diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx index 8d1cfe2c9..2ac728e76 100644 --- a/web/frontend/src/components/models/provider-icon.tsx +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react" const PROVIDER_ICON_SLUGS: Record = { openai: "openai", + elevenlabs: "elevenlabs", anthropic: "anthropic", azure: "microsoftazure", gemini: "googlegemini", @@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record = { const PROVIDER_DOMAINS: Record = { openai: "openai.com", + elevenlabs: "elevenlabs.io", anthropic: "anthropic.com", azure: "azure.com", gemini: "gemini.google.com", diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts index 123640fe5..75eb81e53 100644 --- a/web/frontend/src/components/models/provider-label.ts +++ b/web/frontend/src/components/models/provider-label.ts @@ -1,11 +1,19 @@ +import type { ModelProviderOption } from "@/api/models" + const PROVIDER_LABELS: Record = { openai: "OpenAI", + bedrock: "AWS Bedrock", + elevenlabs: "ElevenLabs ASR", anthropic: "Anthropic", + "anthropic-messages": "Anthropic Messages", azure: "Azure OpenAI", gemini: "Google Gemini", deepseek: "DeepSeek", + "coding-plan": "Alibaba Coding Plan", + "coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)", "qwen-portal": "Qwen (阿里云)", "qwen-intl": "Qwen International", + "qwen-us": "Qwen US", moonshot: "Moonshot (月之暗面)", groq: "Groq", openrouter: "OpenRouter", @@ -15,8 +23,11 @@ const PROVIDER_LABELS: Record = { shengsuanyun: "ShengsuanYun (神算云)", antigravity: "Google Code Assist", "github-copilot": "GitHub Copilot", + "claude-cli": "Claude CLI (local)", + "codex-cli": "Codex CLI (local)", ollama: "Ollama (local)", lmstudio: "LM Studio (local)", + litellm: "LiteLLM", mistral: "Mistral AI", avian: "Avian", vllm: "VLLM (local)", @@ -28,6 +39,7 @@ const PROVIDER_LABELS: Record = { minimax: "MiniMax", longcat: "LongCat", modelscope: "ModelScope (魔搭社区)", + novita: "Novita AI", } const PROVIDER_ALIASES: Record = { @@ -40,6 +52,48 @@ const PROVIDER_ALIASES: Record = { "google-antigravity": "antigravity", } +export const PROVIDER_PRIORITY: Record = { + volcengine: 0, + openai: 1, + gemini: 2, + anthropic: 3, + bedrock: 4, + elevenlabs: 5, + "anthropic-messages": 6, + zhipu: 7, + deepseek: 8, + openrouter: 9, + "qwen-portal": 10, + "qwen-intl": 11, + "qwen-us": 12, + moonshot: 13, + groq: 14, + "coding-plan": 15, + "coding-plan-anthropic": 16, + "github-copilot": 17, + antigravity: 18, + nvidia: 19, + cerebras: 20, + shengsuanyun: 21, + venice: 22, + vivgrid: 23, + minimax: 24, + longcat: 25, + modelscope: 26, + mistral: 27, + avian: 28, + novita: 29, + azure: 30, + litellm: 31, + ollama: 32, + vllm: 33, + lmstudio: 34, + "claude-cli": 35, + "codex-cli": 36, + zai: 37, + mimo: 38, +} + export function getProviderKey(provider?: string): string { const normalized = provider?.trim().toLowerCase() if (!normalized) return "openai" @@ -50,3 +104,45 @@ export function getProviderLabel(provider?: string): string { const prefix = getProviderKey(provider) return PROVIDER_LABELS[prefix] ?? prefix } + +export function findProviderOption( + provider: string | undefined, + options: ModelProviderOption[], +): ModelProviderOption | undefined { + const providerKey = getProviderKey(provider) + return options.find((option) => option.id === providerKey) +} + +export function getProviderDefaultAPIBase( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_api_base ?? "" +} + +export function getSortedProviderOptions( + options: ModelProviderOption[], +): ModelProviderOption[] { + return [...options].sort((a, b) => { + const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER + const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER + if (aPriority !== bPriority) { + return aPriority - bPriority + } + return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id)) + }) +} + +export function getProviderDefaultAuthMethod( + provider: string | undefined, + options: ModelProviderOption[], +): string { + return findProviderOption(provider, options)?.default_auth_method ?? "" +} + +export function isProviderAuthMethodLocked( + provider: string | undefined, + options: ModelProviderOption[], +): boolean { + return findProviderOption(provider, options)?.auth_method_locked === true +} diff --git a/web/frontend/src/hooks/use-chat-models.ts b/web/frontend/src/hooks/use-chat-models.ts index 337bea8db..98566f70f 100644 --- a/web/frontend/src/hooks/use-chat-models.ts +++ b/web/frontend/src/hooks/use-chat-models.ts @@ -27,17 +27,26 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { const [defaultModelName, setDefaultModelName] = useState("") const setDefaultRequestIdRef = useRef(0) + const syncDefaultModelName = useCallback( + (models: ModelInfo[], defaultModel: string) => { + if (models.some((m) => m.model_name === defaultModel)) { + setDefaultModelName(defaultModel) + return + } + setDefaultModelName("") + }, + [], + ) + const loadModels = useCallback(async () => { try { const data = await getModels() setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) } catch { // silently fail } - }, []) + }, [syncDefaultModelName]) useEffect(() => { const timerId = setTimeout(() => { @@ -60,9 +69,7 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { } setModelList(data.models) - if (data.models.some((m) => m.model_name === data.default_model)) { - setDefaultModelName(data.default_model) - } + syncDefaultModelName(data.models, data.default_model) const gateway = await refreshGatewayState({ force: true }) showSaveSuccessOrRestartToast( t, @@ -75,30 +82,41 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) { toast.error(err instanceof Error ? err.message : t("models.loadError")) } }, - [defaultModelName, t], + [defaultModelName, syncDefaultModelName, t], + ) + + const defaultSelectableModels = useMemo( + () => + modelList.filter( + (m) => m.default_model_allowed !== false && m.is_virtual !== true, + ), + [modelList], ) const hasAvailableModels = useMemo( - () => modelList.some((m) => m.available), - [modelList], + () => defaultSelectableModels.some((m) => m.available), + [defaultSelectableModels], ) const oauthModels = useMemo( - () => modelList.filter((m) => m.available && m.auth_method === "oauth"), - [modelList], + () => + defaultSelectableModels.filter( + (m) => m.available && m.auth_method === "oauth", + ), + [defaultSelectableModels], ) const localModels = useMemo( - () => modelList.filter((m) => m.available && isLocalModel(m)), - [modelList], + () => defaultSelectableModels.filter((m) => m.available && isLocalModel(m)), + [defaultSelectableModels], ) const apiKeyModels = useMemo( () => - modelList.filter( + defaultSelectableModels.filter( (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m), ), - [modelList], + [defaultSelectableModels], ) return { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 634e509a2..029691aba 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -236,7 +236,8 @@ "setting": "Setting as default...", "unavailable": "Cannot set unavailable model as default", "isDefault": "Already the default model", - "isVirtual": "Cannot set virtual model as default" + "isVirtual": "Cannot set virtual model as default", + "unsupportedProvider": "This provider is ASR-only and cannot be the default chat model" }, "deleteDisabled": { "isDefault": "Cannot delete the default model" @@ -244,7 +245,9 @@ }, "defaultOnSave": { "label": "Default Model", - "description": "Automatically set this model as default after saving." + "description": "Automatically set this model as default after saving.", + "unsupportedProvider": "This provider can be saved in model_list, but it cannot be used as the default chat model.", + "clearOnSave": "Saving this ASR-only model will clear the current default chat model selection." }, "add": { "button": "Add Model", @@ -255,7 +258,7 @@ "modelNameHint": "A short name used to identify this model in conversations.", "modelId": "Model Identifier", "modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o", - "modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.", + "modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.", "errorRequired": "This field is required.", "errorDuplicateModelName": "Model alias already exists. Please use a different name.", "saveError": "Failed to add model", @@ -272,8 +275,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "e.g. openai", - "providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.", + "providerPlaceholder": "Select a provider", + "providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.", + "providerInvalid": "The current Provider is invalid. Select a supported Provider.", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "Enter your API key", @@ -282,6 +286,7 @@ "proxyHint": "Optional. e.g. http://127.0.0.1:7890", "authMethod": "Auth Method", "authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.", + "authMethodManagedHint": "This Provider manages its authentication mode automatically.", "connectMode": "Connect Mode", "connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.", "workspace": "Workspace Path", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 3cd6f6c54..c2076135e 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -236,7 +236,8 @@ "setting": "正在设为默认...", "unavailable": "无法将不可用的模型设为默认", "isDefault": "该模型已是默认模型", - "isVirtual": "无法将虚拟模型设为默认" + "isVirtual": "无法将虚拟模型设为默认", + "unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型" }, "deleteDisabled": { "isDefault": "无法删除默认模型" @@ -244,7 +245,9 @@ }, "defaultOnSave": { "label": "默认模型", - "description": "保存后自动将该模型设置为默认模型。" + "description": "保存后自动将该模型设置为默认模型。", + "unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。", + "clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。" }, "add": { "button": "添加模型", @@ -255,7 +258,7 @@ "modelNameHint": "用于在对话中识别此模型的简短名称。", "modelId": "模型标识符", "modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o", - "modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。", + "modelIdHint": "此字段将作为所选 Provider 的规范模型 ID 使用。若模型标识符本身包含斜杠(如 openai/gpt-5.4),将作为完整 ID 保留,不会再次拆分 Provider。", "errorRequired": "此字段为必填项。", "errorDuplicateModelName": "模型别名已存在,请使用其他名称。", "saveError": "添加模型失败", @@ -272,8 +275,9 @@ }, "field": { "provider": "Provider", - "providerPlaceholder": "例如 openai", - "providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。", + "providerPlaceholder": "请选择 Provider", + "providerHint": "请选择一个由后端 catalog 提供的 Provider;“模型标识符”字段会按该 Provider 的规范模型 ID 解释。", + "providerInvalid": "当前 Provider 无效,请重新选择一个受支持的 Provider。", "apiBase": "API Base URL", "apiKey": "API Key", "apiKeyPlaceholder": "请输入 API Key", @@ -282,6 +286,7 @@ "proxyHint": "可选。例如 http://127.0.0.1:7890", "authMethod": "认证方式", "authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。", + "authMethodManagedHint": "该 Provider 的认证方式由系统自动管理。", "connectMode": "连接模式", "connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。", "workspace": "工作目录", From ad78ba06ea60df10e643b6cc771af58d98ef5955 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 7 May 2026 16:41:19 +0900 Subject: [PATCH 33/71] fix(line): close HTTP response body from WithHttpInfo calls Fix bodyclose linter errors by ensuring resp.Body is closed after all *WithHttpInfo SDK calls. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/channels/line/line.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 6cc9f0cd9..61d2ee18f 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -459,10 +459,13 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { - _, _, err := c.client.WithContext(ctx).ReplyMessageWithHttpInfo(&messaging_api.ReplyMessageRequest{ + resp, _, err := c.client.WithContext(ctx).ReplyMessageWithHttpInfo(&messaging_api.ReplyMessageRequest{ ReplyToken: tokenEntry.token, Messages: []messaging_api.MessageInterface{&textMsg}, }) + if resp != nil && resp.Body != nil { + resp.Body.Close() + } if err == nil { logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, @@ -566,6 +569,9 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e // classifySDKError maps an SDK HTTP response to the project's sentinel errors. func classifySDKError(resp *http.Response, err error) error { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } if err == nil { return nil } From 41d6156dce2e875aba3986c02bfdf16505d7e365 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 7 May 2026 16:48:45 +0900 Subject: [PATCH 34/71] style(line): shorten long line for golines linter Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/channels/line/line.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 61d2ee18f..87eecd014 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -583,10 +583,11 @@ func classifySDKError(resp *http.Response, err error) error { // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { - resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(&messaging_api.ShowLoadingAnimationRequest{ + req := &messaging_api.ShowLoadingAnimationRequest{ ChatId: chatID, LoadingSeconds: 60, - }) + } + resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(req) return classifySDKError(resp, err) } From f1f6e1131b0bdc1e62e3d0abf245f8b9487acf47 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 7 May 2026 13:20:39 +0200 Subject: [PATCH 35/71] removed unused code --- pkg/agent/context.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index e0ea70f97..b5776b59c 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -275,13 +275,6 @@ Each part separated by the marker will be sent as an independent message.`, return stack.Parts() } -func (cb *ContextBuilder) buildAgentDiscoveryContext() string { - if cb.agentDiscovery == nil { - return "" - } - return formatAgentDiscoverySection(cb.agentDiscovery(cb.workspace)) -} - // BuildSystemPromptWithCache returns the cached system prompt if available // and source files haven't changed, otherwise builds and caches it. // Source file changes are detected via mtime checks (cheap stat calls). From 27bd816b1c832fdfae662c64842cfdb183e02d14 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 7 May 2026 13:49:23 +0200 Subject: [PATCH 36/71] fix(agent): validate AGENT tool declarations from registry --- pkg/agent/agent_init.go | 2 + pkg/agent/instance.go | 4 +- pkg/agent/tool_allowlist.go | 102 +++++++++++++------------------ pkg/agent/tool_allowlist_test.go | 55 ++++++++++++++--- 4 files changed, 92 insertions(+), 71 deletions(-) diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go index e95fbe7f8..8420cd101 100644 --- a/pkg/agent/agent_init.go +++ b/pkg/agent/agent_init.go @@ -352,5 +352,7 @@ func registerSharedTools( }) agent.Tools.Register(delegateTool) } + + warnOnUnknownAgentToolDeclarations(agentID, agent.Workspace, agent.Definition, agent.Tools) } } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 6d629ac57..ac2955334 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -38,6 +38,7 @@ type AgentInstance struct { Sessions session.SessionStore ContextBuilder *ContextBuilder Tools *tools.ToolRegistry + Definition AgentContextDefinition Subagents *config.SubagentsConfig SkillsFilter []string MCPServerAllowlist map[string]struct{} @@ -149,7 +150,7 @@ func NewAgentInstance( subagents = agentCfg.Subagents skillsFilter = resolveAgentSkillsFilter(agentCfg, definition) } - warnOnUnknownAgentDeclarations(agentID, workspace, cfg, definition) + warnOnUnknownAgentMCPServerDeclarations(agentID, workspace, cfg, definition) maxIter := defaults.MaxToolIterations if maxIter == 0 { @@ -256,6 +257,7 @@ func NewAgentInstance( Sessions: sessions, ContextBuilder: contextBuilder, Tools: toolsRegistry, + Definition: definition, Subagents: subagents, SkillsFilter: skillsFilter, MCPServerAllowlist: agentMCPServerAllowlist, diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index b220f1903..87ae2ee4c 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -6,11 +6,31 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/tools" ) const dynamicMCPToolPrefix = "mcp_" -func warnOnUnknownAgentDeclarations( +func warnOnUnknownAgentToolDeclarations( + agentID, workspace string, + definition AgentContextDefinition, + registry *tools.ToolRegistry, +) { + if registry == nil || frontmatterParseFailed(definition) { + return + } + + if unknownTools := unknownAgentToolNames(registry, definition); len(unknownTools) > 0 { + logger.WarnCF("agent", "AGENT.md declares unregistered tool names", + map[string]any{ + "agent_id": agentID, + "workspace": workspace, + "tools": unknownTools, + }) + } +} + +func warnOnUnknownAgentMCPServerDeclarations( agentID, workspace string, cfg *config.Config, definition AgentContextDefinition, @@ -19,15 +39,6 @@ func warnOnUnknownAgentDeclarations( return } - if unknownTools := unknownAgentToolNames(cfg, definition); len(unknownTools) > 0 { - logger.WarnCF("agent", "AGENT.md declares unknown tool names", - map[string]any{ - "agent_id": agentID, - "workspace": workspace, - "tools": unknownTools, - }) - } - if unknownServers := unknownAgentMCPServerNames(cfg, definition); len(unknownServers) > 0 { logger.WarnCF("agent", "AGENT.md declares unknown MCP server names", map[string]any{ @@ -38,12 +49,15 @@ func warnOnUnknownAgentDeclarations( } } -func unknownAgentToolNames(cfg *config.Config, definition AgentContextDefinition) []string { +func unknownAgentToolNames( + registry *tools.ToolRegistry, + definition AgentContextDefinition, +) []string { if definition.Agent == nil || definition.Agent.Frontmatter.Tools == nil { return nil } - known := knownRuntimeToolNames(cfg) + known := registeredRuntimeToolNames(registry) unknown := make(map[string]struct{}) for _, raw := range definition.Agent.Frontmatter.Tools { name := strings.ToLower(strings.TrimSpace(raw)) @@ -59,6 +73,21 @@ func unknownAgentToolNames(cfg *config.Config, definition AgentContextDefinition return sortedKeys(unknown) } +func registeredRuntimeToolNames(registry *tools.ToolRegistry) map[string]struct{} { + known := make(map[string]struct{}) + if registry == nil { + return known + } + for _, raw := range registry.List() { + name := strings.ToLower(strings.TrimSpace(raw)) + if name == "" { + continue + } + known[name] = struct{}{} + } + return known +} + func unknownAgentMCPServerNames(cfg *config.Config, definition AgentContextDefinition) []string { if cfg == nil || definition.Agent == nil || definition.Agent.Frontmatter.MCPServers == nil { return nil @@ -79,55 +108,6 @@ func unknownAgentMCPServerNames(cfg *config.Config, definition AgentContextDefin return sortedKeys(unknown) } -func knownRuntimeToolNames(cfg *config.Config) map[string]struct{} { - known := make(map[string]struct{}) - if cfg == nil { - return known - } - - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("read_file"), "read_file") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("write_file"), "write_file") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("list_dir"), "list_dir") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("exec"), "exec") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("edit_file"), "edit_file") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("append_file"), "append_file") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("cron"), "cron") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("web"), "web_search") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("web_fetch"), "web_fetch") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("i2c"), "i2c") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("spi"), "spi") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("message"), "message") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("send_file"), "send_file") - addKnownToolIfEnabled( - known, - cfg.Tools.IsToolEnabled("skills") && cfg.Tools.IsToolEnabled("find_skills"), - "find_skills", - ) - addKnownToolIfEnabled( - known, - cfg.Tools.IsToolEnabled("skills") && cfg.Tools.IsToolEnabled("install_skill"), - "install_skill", - ) - if cfg.Tools.IsToolEnabled("subagent") { - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("spawn"), "spawn") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("subagent"), "subagent") - addKnownToolIfEnabled(known, cfg.Tools.IsToolEnabled("spawn_status"), "spawn_status") - } - if cfg.Tools.IsToolEnabled("mcp") && cfg.Tools.MCP.Discovery.Enabled { - addKnownToolIfEnabled(known, cfg.Tools.MCP.Discovery.UseRegex, "tool_search_tool_regex") - addKnownToolIfEnabled(known, cfg.Tools.MCP.Discovery.UseBM25, "tool_search_tool_bm25") - } - - return known -} - -func addKnownToolIfEnabled(known map[string]struct{}, enabled bool, name string) { - if !enabled { - return - } - known[name] = struct{}{} -} - func sortedKeys(values map[string]struct{}) []string { if len(values) == 0 { return nil diff --git a/pkg/agent/tool_allowlist_test.go b/pkg/agent/tool_allowlist_test.go index 059ee9344..46bbac2bc 100644 --- a/pkg/agent/tool_allowlist_test.go +++ b/pkg/agent/tool_allowlist_test.go @@ -1,11 +1,32 @@ package agent import ( + "context" "testing" "github.com/sipeed/picoclaw/pkg/config" + agenttools "github.com/sipeed/picoclaw/pkg/tools" ) +type allowlistTestTool struct { + name string +} + +func (t *allowlistTestTool) Name() string { return t.name } + +func (t *allowlistTestTool) Description() string { return "test tool" } + +func (t *allowlistTestTool) Parameters() map[string]any { + return map[string]any{"type": "object"} +} + +func (t *allowlistTestTool) Execute( + _ context.Context, + _ map[string]any, +) *agenttools.ToolResult { + return agenttools.NewToolResult("ok") +} + func TestUnknownAgentToolNames(t *testing.T) { workspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- @@ -16,21 +37,37 @@ tools: [read_file, web_serach, mcp_github_search] }) defer cleanupWorkspace(t, workspace) - cfg := &config.Config{ - Tools: config.ToolsConfig{ - ReadFile: config.ReadFileToolConfig{Enabled: true}, - Web: config.WebToolsConfig{ - ToolConfig: config.ToolConfig{Enabled: true}, - }, - }, - } + registry := agenttools.NewToolRegistry() + registry.Register(&allowlistTestTool{name: "read_file"}) + registry.Register(&allowlistTestTool{name: "web_search"}) - unknown := unknownAgentToolNames(cfg, loadAgentDefinition(workspace)) + unknown := unknownAgentToolNames(registry, loadAgentDefinition(workspace)) if len(unknown) != 1 || unknown[0] != "web_serach" { t.Fatalf("unknownAgentToolNames() = %v, want [web_serach]", unknown) } } +func TestUnknownAgentToolNamesUsesRegisteredRuntimeTools(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +tools: [serial, reaction, send_tts, load_image, delegate, made_up] +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + registry := agenttools.NewToolRegistry() + for _, name := range []string{"serial", "reaction", "send_tts", "load_image", "delegate"} { + registry.Register(&allowlistTestTool{name: name}) + } + + unknown := unknownAgentToolNames(registry, loadAgentDefinition(workspace)) + if len(unknown) != 1 || unknown[0] != "made_up" { + t.Fatalf("unknownAgentToolNames() = %v, want [made_up]", unknown) + } +} + func TestUnknownAgentMCPServerNames(t *testing.T) { workspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- From dd8e247550b62eb6b9b974b1fed5e92eba1fc343 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 7 May 2026 14:01:43 +0200 Subject: [PATCH 37/71] fix(agent): align MCP prompt registration with tool allowlist --- pkg/agent/agent_mcp.go | 82 ++++++++++++++++++++++++++++--------- pkg/agent/agent_mcp_test.go | 37 +++++++++++++++++ pkg/tools/registry.go | 9 ++++ pkg/tools/registry_test.go | 22 ++++++++++ 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index 1350ba1f2..d04a0fdf3 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -144,25 +144,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { // Per-server "deferred" field takes precedence over the global Discovery.Enabled. serverCfg := mcpCfg.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(), - }) - } - } + registeredToolsByAgent := make(map[string]map[string]struct{}, len(agentIDs)) for _, tool := range conn.Tools { for _, agentID := range agentIDs { @@ -181,6 +163,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) + toolName := mcpTool.Name() mcpTool.SetWorkspace(agent.Workspace) mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars()) mcpTool.SetEventPublisher(al.runtimeEvents) @@ -190,18 +173,36 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } else { agent.Tools.Register(mcpTool) } + if !toolRegistryIncludes(agent.Tools, toolName) { + continue + } + recordRegisteredMCPTool(registeredToolsByAgent, agentID, toolName) totalRegistrations++ logger.DebugCF("agent", "Registered MCP tool", map[string]any{ "agent_id": agentID, "server": serverName, "tool": tool.Name, - "name": mcpTool.Name(), + "name": toolName, "deferred": registerAsHidden, }) } } + + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + registerMCPServerPromptContributor( + agentID, + agent, + serverName, + len(registeredToolsByAgent[agentID]), + registerAsHidden, + ) + } } logger.InfoCF("agent", "MCP tools registered successfully", map[string]any{ @@ -265,6 +266,47 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { return al.mcp.getInitErr() } +func registerMCPServerPromptContributor( + agentID string, + agent *AgentInstance, + serverName string, + toolCount int, + registerAsHidden bool, +) { + if agent == nil || agent.ContextBuilder == nil || toolCount <= 0 { + return + } + if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{ + serverName: serverName, + toolCount: toolCount, + deferred: registerAsHidden, + }); err != nil { + logger.WarnCF("agent", "Failed to register MCP prompt contributor", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "error": err.Error(), + }) + } +} + +func recordRegisteredMCPTool( + registeredToolsByAgent map[string]map[string]struct{}, + agentID, toolName string, +) { + if registeredToolsByAgent[agentID] == nil { + registeredToolsByAgent[agentID] = make(map[string]struct{}) + } + registeredToolsByAgent[agentID][toolName] = struct{}{} +} + +func toolRegistryIncludes(registry *tools.ToolRegistry, name string) bool { + if registry == nil { + return false + } + return registry.HasRegistered(name) +} + func filterMCPConfigServers( mcpCfg config.MCPConfig, allowed map[string]struct{}, diff --git a/pkg/agent/agent_mcp_test.go b/pkg/agent/agent_mcp_test.go index b68fcc2c1..5c3f67445 100644 --- a/pkg/agent/agent_mcp_test.go +++ b/pkg/agent/agent_mcp_test.go @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/mcp" + agenttools "github.com/sipeed/picoclaw/pkg/tools" ) func boolPtr(b bool) *bool { return &b } @@ -135,6 +136,42 @@ func TestServerIsDeferred(t *testing.T) { } } +func TestRegisterMCPServerPromptContributorUsesActualRegisteredToolCount(t *testing.T) { + cb := NewContextBuilder(t.TempDir()) + agent := &AgentInstance{ContextBuilder: cb} + + registerMCPServerPromptContributor("research", agent, "github", 0, false) + messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + if prompt := messages[0].Content; strings.Contains(prompt, "MCP server `github`") { + t.Fatalf("expected no MCP prompt when no tools were registered, got %q", prompt) + } + + registerMCPServerPromptContributor("research", agent, "github", 2, false) + messages = cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + prompt := messages[0].Content + if !strings.Contains(prompt, "MCP server `github` is connected") { + t.Fatalf("expected MCP prompt for registered tools, got %q", prompt) + } + if !strings.Contains(prompt, "It contributes 2 tool(s)") { + t.Fatalf("expected actual registered tool count in prompt, got %q", prompt) + } +} + +func TestToolRegistryIncludesReportsOnlyRegisteredTools(t *testing.T) { + registry := agenttools.NewToolRegistry() + registry.SetAllowlist([]string{"mcp_github_search"}) + + registry.RegisterHidden(&allowlistTestTool{name: "mcp_github_search"}) + registry.RegisterHidden(&allowlistTestTool{name: "mcp_github_create_issue"}) + + if !toolRegistryIncludes(registry, "mcp_github_search") { + t.Fatal("expected hidden registered MCP tool to be included") + } + if toolRegistryIncludes(registry, "mcp_github_create_issue") { + t.Fatal("blocked MCP tool should not be included") + } +} + func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) { al, cfg, _, _, cleanup := newTestAgentLoop(t) defer cleanup() diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index a68746b82..f8f0aa3fd 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -176,6 +176,15 @@ func (r *ToolRegistry) toolAllowedLocked(name string) bool { return ok } +// HasRegistered reports whether a tool name is present in the registry, +// including hidden tools whose TTL is currently zero. +func (r *ToolRegistry) HasRegistered(name string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + _, ok := r.tools[name] + return ok +} + // HiddenToolSnapshot holds a consistent snapshot of hidden tools and the // registry version at which it was taken. Used by BM25SearchTool cache. type HiddenToolSnapshot struct { diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 5ce79e227..f75a321f2 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -130,6 +130,28 @@ func TestToolRegistry_AllowlistFiltersRegistrations(t *testing.T) { } } +func TestToolRegistry_HasRegisteredIncludesHiddenTools(t *testing.T) { + r := NewToolRegistry() + r.SetAllowlist([]string{"visible", "hidden"}) + + r.Register(newMockTool("visible", "visible")) + r.RegisterHidden(newMockTool("hidden", "hidden")) + r.RegisterHidden(newMockTool("blocked", "blocked")) + + if !r.HasRegistered("visible") { + t.Fatal("expected visible tool to be registered") + } + if !r.HasRegistered("hidden") { + t.Fatal("expected hidden tool to be reported as registered") + } + if r.HasRegistered("blocked") { + t.Fatal("blocked tool should not be registered") + } + if _, ok := r.Get("hidden"); ok { + t.Fatal("hidden tool with zero TTL should not be callable through Get") + } +} + func TestToolRegistry_Get_NotFound(t *testing.T) { r := NewToolRegistry() _, ok := r.Get("nonexistent") From 96fd887cad4bf2b49a8c54121c8779f8b621512e Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 7 May 2026 18:17:37 +0200 Subject: [PATCH 38/71] fix(agent): match MCP server allowlists case-insensitively --- pkg/agent/agent_mcp.go | 10 +++++++++- pkg/agent/agent_mcp_test.go | 32 ++++++++++++++++++++++++++++++++ pkg/agent/tool_allowlist.go | 23 +++++++++++++++++++++-- pkg/agent/tool_allowlist_test.go | 27 +++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index d04a0fdf3..3d569b2bd 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -317,8 +317,16 @@ func filterMCPConfigServers( filtered := mcpCfg filtered.Servers = make(map[string]config.MCPServerConfig) + normalizedAllowed := make(map[string]struct{}, len(allowed)) + for serverName := range allowed { + name := normalizeMCPServerName(serverName) + if name == "" { + continue + } + normalizedAllowed[name] = struct{}{} + } for serverName, serverCfg := range mcpCfg.Servers { - if _, ok := allowed[serverName]; ok { + if _, ok := normalizedAllowed[normalizeMCPServerName(serverName)]; ok { filtered.Servers[serverName] = serverCfg } } diff --git a/pkg/agent/agent_mcp_test.go b/pkg/agent/agent_mcp_test.go index 5c3f67445..7c8a4cd28 100644 --- a/pkg/agent/agent_mcp_test.go +++ b/pkg/agent/agent_mcp_test.go @@ -172,6 +172,38 @@ func TestToolRegistryIncludesReportsOnlyRegisteredTools(t *testing.T) { } } +func TestFilterMCPConfigServersCaseInsensitivePreservesOriginalKeys(t *testing.T) { + mcpCfg := config.MCPConfig{ + Servers: map[string]config.MCPServerConfig{ + "GitHub": {Enabled: true}, + "filesystem": {Enabled: true}, + "Slack": {Enabled: true}, + }, + } + allowed := map[string]struct{}{ + "github": {}, + "FILESYSTEM": {}, + } + + filtered := filterMCPConfigServers(mcpCfg, allowed) + + if len(filtered.Servers) != 2 { + t.Fatalf("filtered.Servers = %v, want 2 entries", filtered.Servers) + } + if _, ok := filtered.Servers["GitHub"]; !ok { + t.Fatal("expected original GitHub config key to be preserved") + } + if _, ok := filtered.Servers["filesystem"]; !ok { + t.Fatal("expected filesystem config key to be preserved") + } + if _, ok := filtered.Servers["github"]; ok { + t.Fatal("did not expect normalized github key to replace original config key") + } + if _, ok := filtered.Servers["Slack"]; ok { + t.Fatal("did not expect unallowed Slack server") + } +} + func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) { al, cfg, _, _, cleanup := newTestAgentLoop(t) defer cleanup() diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index 87ae2ee4c..7a020c82c 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -11,6 +11,24 @@ import ( const dynamicMCPToolPrefix = "mcp_" +func normalizeMCPServerName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func normalizedMCPServerNameSet( + servers map[string]config.MCPServerConfig, +) map[string]struct{} { + normalized := make(map[string]struct{}, len(servers)) + for serverName := range servers { + name := normalizeMCPServerName(serverName) + if name == "" { + continue + } + normalized[name] = struct{}{} + } + return normalized +} + func warnOnUnknownAgentToolDeclarations( agentID, workspace string, definition AgentContextDefinition, @@ -93,13 +111,14 @@ func unknownAgentMCPServerNames(cfg *config.Config, definition AgentContextDefin return nil } + knownServers := normalizedMCPServerNameSet(cfg.Tools.MCP.Servers) unknown := make(map[string]struct{}) for _, raw := range definition.Agent.Frontmatter.MCPServers { - name := strings.ToLower(strings.TrimSpace(raw)) + name := normalizeMCPServerName(raw) if name == "" { continue } - if _, ok := cfg.Tools.MCP.Servers[name]; ok { + if _, ok := knownServers[name]; ok { continue } unknown[name] = struct{}{} diff --git a/pkg/agent/tool_allowlist_test.go b/pkg/agent/tool_allowlist_test.go index 46bbac2bc..4851dcaa8 100644 --- a/pkg/agent/tool_allowlist_test.go +++ b/pkg/agent/tool_allowlist_test.go @@ -93,3 +93,30 @@ mcpServers: [github, githb] t.Fatalf("unknownAgentMCPServerNames() = %v, want [githb]", unknown) } } + +func TestUnknownAgentMCPServerNamesMatchesConfigCaseInsensitively(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +mcpServers: [github, FileSystem, slak] +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + Servers: map[string]config.MCPServerConfig{ + "GitHub": {Enabled: true}, + "filesystem": {Enabled: true}, + }, + }, + }, + } + + unknown := unknownAgentMCPServerNames(cfg, loadAgentDefinition(workspace)) + if len(unknown) != 1 || unknown[0] != "slak" { + t.Fatalf("unknownAgentMCPServerNames() = %v, want [slak]", unknown) + } +} From b8f4257ceefbc798abd6d7fc86c18f11bb78e734 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Thu, 7 May 2026 18:26:09 +0200 Subject: [PATCH 39/71] fix(agent): filter discovery by spawn permissions --- docs/guides/configuration.it.md | 12 ++-- docs/guides/configuration.md | 12 ++-- pkg/agent/context.go | 9 +-- pkg/agent/discovery.go | 39 ++++++++++- pkg/agent/discovery_test.go | 117 +++++++++++++++++++++++++++++-- pkg/agent/prompt_contributors.go | 6 +- pkg/agent/registry.go | 9 ++- 7 files changed, 170 insertions(+), 34 deletions(-) diff --git a/docs/guides/configuration.it.md b/docs/guides/configuration.it.md index 4b0153e2c..d7de46895 100644 --- a/docs/guides/configuration.it.md +++ b/docs/guides/configuration.it.md @@ -98,7 +98,7 @@ Note: ### Discovery Multi-Agent (Automatica) -Quando esiste più di un agent, PicoClaw inietta automaticamente nel system prompt di ogni agent un registry strutturato dei peer. Non serve una chiamata aggiuntiva a un tool `list_agents`. +Quando un agent ha peer spawnabili, PicoClaw inietta automaticamente nel suo system prompt un registry strutturato dei peer. Non serve una chiamata aggiuntiva a un tool `list_agents`. Questa discovery serve soprattutto a rendere affidabile la delega tramite `spawn` con `agent_id` esplicito. @@ -112,9 +112,10 @@ Ogni entry include: Dettagli importanti: -- La sezione include anche l'entry dell'agent corrente, quindi c'è self-awareness. +- La sezione include solo i peer che l'agent corrente può spawnare tramite `subagents.allow_agents`. +- L'agent corrente e i peer non spawnabili vengono omessi, così il modello non pianifica contro agent non disponibili. - La discovery è volutamente leggera. Fornisce al modello solo l'identità necessaria per scegliere un peer: `id`, `name`, `description`. -- `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. +- `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. Questi permessi controllano anche la visibilità nella discovery. - `AGENT.md` resta il layer di identità. Il codice runtime e i tool possono comunque usare `tools`, `skills`, `mcpServers` e `model` quando avviene la delega. Forma dell'oggetto iniettato: @@ -122,11 +123,6 @@ Forma dell'oggetto iniettato: ```json { "agents": [ - { - "id": "main", - "name": "Main Assistant", - "description": "Agent generalista per richieste quotidiane." - }, { "id": "research", "name": "Research Agent", diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index ce0db46fe..4cbe9dd82 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -238,7 +238,7 @@ Notes: ### Agent Discovery (Automatic) -When more than one agent exists, PicoClaw injects a structured agent registry into each agent's system prompt on every turn. No extra `list_agents` tool call is required. +When an agent has spawnable peers, PicoClaw injects a structured agent registry into that agent's system prompt on every turn. No extra `list_agents` tool call is required. This registry is intended to make delegation concrete and reliable, especially when using `spawn` with a target `agent_id`. @@ -252,9 +252,10 @@ Each entry includes: Important behavior: -- The discovery section includes the current agent's own entry, so the model has self-awareness. +- The discovery section includes only peer agents the current agent is permitted to spawn via `subagents.allow_agents`. +- The current agent and non-spawnable peers are omitted, so the model does not plan against unavailable agents. - Discovery is intentionally lightweight. It gives the model only the identity it needs to choose a peer: `id`, `name`, and `description`. -- `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. +- `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. Those permissions also gate discovery visibility. - `AGENT.md` remains the identity layer. Runtime/tool code can still use its `tools`, `skills`, `mcpServers`, and `model` fields when delegation happens. Example injected shape: @@ -262,11 +263,6 @@ Example injected shape: ```json { "agents": [ - { - "id": "main", - "name": "Main Assistant", - "description": "Generalist agent for day-to-day requests." - }, { "id": "research", "name": "Research Agent", diff --git a/pkg/agent/context.go b/pkg/agent/context.go index b5776b59c..7f5b32fef 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -26,7 +26,7 @@ type ContextBuilder struct { skillsLoader *skills.SkillsLoader memory *MemoryStore splitOnMarker bool - agentDiscovery func(workspace string) []AgentDescriptor + agentDiscovery func(agentID string) []AgentDescriptor promptRegistry *PromptRegistry // Cache for system prompt to avoid rebuilding on every call. @@ -68,13 +68,14 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { } func (cb *ContextBuilder) WithAgentDiscovery( - discover func(workspace string) []AgentDescriptor, + agentID string, + discover func(agentID string) []AgentDescriptor, ) *ContextBuilder { cb.agentDiscovery = discover if discover != nil { if err := cb.RegisterPromptContributor(agentDiscoveryPromptContributor{ - workspace: cb.workspace, - discover: discover, + agentID: agentID, + discover: discover, }); err != nil { logger.WarnCF("agent", "Failed to register agent discovery prompt contributor", map[string]any{ "error": err.Error(), diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index d08ed1880..8c1c5bb82 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -60,6 +60,41 @@ func (r *AgentRegistry) ListAgents(workspace string) []AgentDescriptor { return descriptors } +// ListSpawnableAgents returns descriptors only for agents the current agent is +// allowed to spawn. Restricted peers are intentionally omitted from discovery. +func (r *AgentRegistry) ListSpawnableAgents(agentID string) []AgentDescriptor { + r.mu.RLock() + defer r.mu.RUnlock() + + parentID := routing.NormalizeAgentID(agentID) + parent, ok := r.agents[parentID] + if !ok || parent == nil { + return nil + } + + ids := make([]string, 0, len(r.agents)) + for id := range r.agents { + if id == parentID { + continue + } + if !agentAllowsSubagent(parent, id) { + continue + } + ids = append(ids, id) + } + sort.Strings(ids) + + descriptors := make([]AgentDescriptor, 0, len(ids)) + for _, id := range ids { + agent := r.agents[id] + if agent == nil { + continue + } + descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent)) + } + return descriptors +} + // GetAgentDescriptor returns the structured discovery payload for one agent. func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bool) { r.mu.RLock() @@ -195,7 +230,7 @@ func cleanWorkspacePath(path string) string { } func formatAgentDiscoverySection(agents []AgentDescriptor) string { - if len(agents) <= 1 { + if len(agents) == 0 { return "" } @@ -212,7 +247,7 @@ func formatAgentDiscoverySection(agents []AgentDescriptor) string { var header strings.Builder header.WriteString("# Agent Discovery\n\n") - header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") + header.WriteString("This registry lists the peer agents this agent is permitted to spawn.\n") header.WriteString( "Choose a peer based on its description. Use only agent IDs listed here when calling spawn.\n\n", ) diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go index 28da55e25..bceee54d7 100644 --- a/pkg/agent/discovery_test.go +++ b/pkg/agent/discovery_test.go @@ -66,6 +66,30 @@ Handle support tickets carefully. } } +func TestAgentRegistry_ListSpawnableAgentsRespectsPermissions(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + { + ID: "parent", + Default: true, + Subagents: &config.SubagentsConfig{ + AllowAgents: []string{"child2", "child1"}, + }, + }, + {ID: "child1"}, + {ID: "child2"}, + {ID: "restricted"}, + }) + + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + descriptors := registry.ListSpawnableAgents("parent") + if len(descriptors) != 2 { + t.Fatalf("expected 2 spawnable descriptors, got %d: %+v", len(descriptors), descriptors) + } + if descriptors[0].ID != "child1" || descriptors[1].ID != "child2" { + t.Fatalf("expected sorted spawnable peers only, got %+v", descriptors) + } +} + func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) { mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- @@ -90,9 +114,29 @@ Investigate deeply. }) defer cleanupWorkspace(t, researchWorkspace) + restrictedWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: Restricted Agent +description: Restricted specialist +--- +# Agent + +Handle restricted work. +`, + }) + defer cleanupWorkspace(t, restrictedWorkspace) + cfg := testCfg([]config.AgentConfig{ - {ID: "main", Default: true, Workspace: mainWorkspace}, + { + ID: "main", + Default: true, + Workspace: mainWorkspace, + Subagents: &config.SubagentsConfig{ + AllowAgents: []string{"research"}, + }, + }, {ID: "research", Workspace: researchWorkspace}, + {ID: "restricted", Workspace: restrictedWorkspace}, }) cfg.Tools.ReadFile.Enabled = true cfg.Tools.WriteFile.Enabled = true @@ -121,13 +165,16 @@ Investigate deeply. if !strings.Contains(systemPrompt, "# Agent Discovery") { t.Fatalf("expected discovery section in system prompt, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"id": "main"`) || - !strings.Contains(systemPrompt, `"id": "research"`) { - t.Fatalf("expected self and peer descriptors in discovery section, got %q", systemPrompt) + if strings.Contains(systemPrompt, `"id": "main"`) { + t.Fatalf("did not expect self descriptor in discovery section, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"name": "main"`) || + if !strings.Contains(systemPrompt, `"id": "research"`) || !strings.Contains(systemPrompt, `"description": "Research specialist"`) { - t.Fatalf("expected minimal identity fields in discovery section, got %q", systemPrompt) + t.Fatalf("expected allowed peer descriptor in discovery section, got %q", systemPrompt) + } + if strings.Contains(systemPrompt, `"id": "restricted"`) || + strings.Contains(systemPrompt, `"description": "Restricted specialist"`) { + t.Fatalf("did not expect restricted peer descriptor in discovery section, got %q", systemPrompt) } for _, forbidden := range []string{`"current_agent_id"`, `"available_tools"`, `"model"`, `"channels"`, `"skills"`, `"mcpServers"`, `"tools"`} { if strings.Contains(systemPrompt, forbidden) { @@ -136,6 +183,64 @@ Investigate deeply. } } +func TestContextBuilder_BuildMessagesOmitsAgentDiscoveryWithoutSpawnPermissions(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +description: Main agent +--- +# Agent + +Generalist. +`, + }) + defer cleanupWorkspace(t, mainWorkspace) + + researchWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +description: Research specialist +--- +# Agent + +Investigate deeply. +`, + }) + defer cleanupWorkspace(t, researchWorkspace) + + cfg := testCfg([]config.AgentConfig{ + {ID: "main", Default: true, Workspace: mainWorkspace}, + {ID: "research", Workspace: researchWorkspace}, + }) + cfg.Tools.ReadFile.Enabled = true + + registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) + mainAgent, ok := registry.GetAgent("main") + if !ok || mainAgent == nil { + t.Fatal("expected main agent") + } + + messages := mainAgent.ContextBuilder.BuildMessages( + nil, + "", + "handle locally", + nil, + "telegram", + "chat-1", + "", + "", + ) + if len(messages) == 0 { + t.Fatal("expected messages") + } + + systemPrompt := messages[0].Content + if strings.Contains(systemPrompt, "# Agent Discovery") { + t.Fatalf("did not expect discovery section without spawn permissions, got %q", systemPrompt) + } + if strings.Contains(systemPrompt, `"id": "research"`) { + t.Fatalf("did not expect unauthorized peer identity in system prompt, got %q", systemPrompt) + } +} + func TestContextBuilder_BuildMessagesOmitsAgentDiscoverySectionForSingleton(t *testing.T) { mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- diff --git a/pkg/agent/prompt_contributors.go b/pkg/agent/prompt_contributors.go index 863df57b2..d6a2c09ec 100644 --- a/pkg/agent/prompt_contributors.go +++ b/pkg/agent/prompt_contributors.go @@ -94,8 +94,8 @@ func (c mcpServerPromptContributor) ContributePrompt( } type agentDiscoveryPromptContributor struct { - workspace string - discover func(workspace string) []AgentDescriptor + agentID string + discover func(agentID string) []AgentDescriptor } func (c agentDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor { @@ -115,7 +115,7 @@ func (c agentDiscoveryPromptContributor) ContributePrompt( if c.discover == nil { return nil, nil } - content := formatAgentDiscoverySection(c.discover(c.workspace)) + content := formatAgentDiscoverySection(c.discover(c.agentID)) if strings.TrimSpace(content) == "" { return nil, nil } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index c6a1246ac..a4d1a860d 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -57,7 +57,7 @@ func NewAgentRegistry( for _, instance := range registry.agents { if instance.ContextBuilder != nil { - instance.ContextBuilder.WithAgentDiscovery(registry.ListAgents) + instance.ContextBuilder.WithAgentDiscovery(instance.ID, registry.ListSpawnableAgents) } } @@ -119,10 +119,13 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo if !ok { return false } - if parent.Subagents == nil || parent.Subagents.AllowAgents == nil { + return agentAllowsSubagent(parent, routing.NormalizeAgentID(targetAgentID)) +} + +func agentAllowsSubagent(parent *AgentInstance, targetNorm string) bool { + if parent == nil || parent.Subagents == nil || parent.Subagents.AllowAgents == nil { return false } - targetNorm := routing.NormalizeAgentID(targetAgentID) for _, allowed := range parent.Subagents.AllowAgents { if allowed == "*" { return true From e948106d50fe8645af9e3c1d47b71f2e05d39dd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 17:24:06 +0000 Subject: [PATCH 40/71] build(deps): bump github.com/google/jsonschema-go from 0.4.2 to 0.4.3 Bumps [github.com/google/jsonschema-go](https://github.com/google/jsonschema-go) from 0.4.2 to 0.4.3. - [Release notes](https://github.com/google/jsonschema-go/releases) - [Commits](https://github.com/google/jsonschema-go/compare/v0.4.2...0.4.3) --- updated-dependencies: - dependency-name: github.com/google/jsonschema-go dependency-version: 0.4.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f52e328cf..9fecb21f0 100644 --- a/go.mod +++ b/go.mod @@ -118,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 + github.com/google/jsonschema-go v0.4.3 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 d43e48f5b..51b08ee86 100644 --- a/go.sum +++ b/go.sum @@ -142,8 +142,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= From 1c25dcd239956a2d2bc6bd4bf49f4418ed847edc Mon Sep 17 00:00:00 2001 From: Mauro Date: Fri, 8 May 2026 03:33:17 +0200 Subject: [PATCH 41/71] build(go): bump Go to 1.25.10 to fix stdlib vulnerabilities (#2818) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9fecb21f0..5ab3c9d3a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/sipeed/picoclaw -go 1.25.9 +go 1.25.10 require ( fyne.io/systray v1.12.0 From d0ab5aed7a3e582443f0ada45c63a192c474605b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 10:47:29 +0800 Subject: [PATCH 42/71] build(deps): bump fyne.io/systray from 1.12.0 to 1.12.1 (#2803) Bumps [fyne.io/systray](https://github.com/fyne-io/systray) from 1.12.0 to 1.12.1. - [Changelog](https://github.com/fyne-io/systray/blob/master/CHANGELOG.md) - [Commits](https://github.com/fyne-io/systray/compare/v1.12.0...v1.12.1) --- updated-dependencies: - dependency-name: fyne.io/systray dependency-version: 1.12.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5ab3c9d3a..0023aae82 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/sipeed/picoclaw go 1.25.10 require ( - fyne.io/systray v1.12.0 + fyne.io/systray v1.12.1 github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 diff --git a/go.sum b/go.sum index 51b08ee86..432aa7f8b 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 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= +fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= +fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= 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= From b7edd35d132579df84af78f6d77003060f74239d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 10:50:08 +0800 Subject: [PATCH 43/71] build(deps): bump shadcn from 4.3.0 to 4.7.0 in /web/frontend (#2804) Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.3.0 to 4.7.0. - [Release notes](https://github.com/shadcn-ui/ui/releases) - [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md) - [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.7.0/packages/shadcn) --- updated-dependencies: - dependency-name: shadcn dependency-version: 4.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 282 ++++++++++++++++++++---------------- 2 files changed, 155 insertions(+), 129 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index bf3e7921b..db4284906 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -40,7 +40,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", - "shadcn": "^4.3.0", + "shadcn": "^4.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 78639de19..4804dea24 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -78,8 +78,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 shadcn: - specifier: ^4.3.0 - version: 4.3.0(@types/node@25.6.0)(typescript@5.9.3) + specifier: ^4.7.0 + version: 4.7.0(@types/node@25.6.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -160,8 +160,8 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -180,8 +180,8 @@ packages: resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -243,6 +243,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -289,8 +294,8 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@dotenvx/dotenvx@1.61.0': - resolution: {integrity: sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==} + '@dotenvx/dotenvx@1.65.0': + resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==} hasBin: true '@ecies/ciphers@0.2.6': @@ -547,8 +552,8 @@ packages: resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/confirm@6.0.11': - resolution: {integrity: sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==} + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -556,8 +561,8 @@ packages: '@types/node': optional: true - '@inquirer/core@11.1.8': - resolution: {integrity: sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==} + '@inquirer/core@11.1.9': + resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' @@ -604,8 +609,8 @@ packages: '@cfworker/json-schema': optional: true - '@mswjs/interceptors@0.41.3': - resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + '@mswjs/interceptors@0.41.8': + resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} engines: {node: '>=18'} '@napi-rs/wasm-runtime@1.1.4': @@ -1884,8 +1889,8 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1935,8 +1940,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.17: - resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1984,8 +1989,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001787: - resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2191,8 +2196,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.334: - resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} + electron-to-chromium@1.5.352: + resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2322,8 +2327,8 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -2338,8 +2343,8 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - express-rate-limit@8.3.2: - resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} + express-rate-limit@8.5.1: + resolution: {integrity: sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2370,8 +2375,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -2431,8 +2436,8 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} fsevents@2.3.3: @@ -2509,16 +2514,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + graphql@16.14.0: + resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} hast-util-from-parse5@8.0.3: @@ -2564,8 +2569,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.14: - resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} html-parse-stringify@3.0.1: @@ -2630,8 +2635,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2747,8 +2752,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} jotai@2.19.1: resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==} @@ -2803,8 +2808,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3106,8 +3111,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.13.4: - resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} + msw@2.14.4: + resolution: {integrity: sha512-HVPZJ9Rx4nDCWhjNQ57lKQGSE+0zDHw0xWE2IN2rLOUTLkagEBWNlvWuKYNwG2pQWq96TMd8NiSK/6vO1udnWQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -3125,6 +3130,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3141,8 +3151,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -3285,6 +3295,10 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -3515,8 +3529,8 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.11.7: - resolution: {integrity: sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -3587,8 +3601,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.3.0: - resolution: {integrity: sha512-7vhnBh2LVLyxOd1ZQWwXv7OATCnQcxdqc8FbZdNigZriNOwDsHklQmPpvPt1jcrFK5mzMI+cyuAYv8WzERx2Og==} + shadcn@4.7.0: + resolution: {integrity: sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==} hasBin: true shebang-command@2.0.0: @@ -3723,11 +3737,11 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tldts-core@7.0.28: - resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + tldts-core@7.0.30: + resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} - tldts@7.0.28: - resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + tldts@7.0.30: + resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true to-regex-range@5.0.1: @@ -3776,8 +3790,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@5.5.0: - resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} type-is@2.0.1: @@ -4025,8 +4039,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-spinner@1.1.0: - resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==} + yocto-spinner@1.2.0: + resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==} engines: {node: '>=18.19'} yoctocolors@2.1.2: @@ -4061,7 +4075,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -4070,7 +4084,7 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -4085,7 +4099,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -4097,13 +4111,13 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 @@ -4178,6 +4192,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -4200,7 +4218,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) @@ -4223,7 +4241,7 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': @@ -4231,7 +4249,7 @@ snapshots: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 @@ -4243,7 +4261,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@dotenvx/dotenvx@1.61.0': + '@dotenvx/dotenvx@1.65.0': dependencies: commander: 11.1.0 dotenv: 17.4.2 @@ -4254,7 +4272,7 @@ snapshots: object-treeify: 1.1.33 picomatch: 4.0.4 which: 4.0.0 - yocto-spinner: 1.1.0 + yocto-spinner: 1.2.0 '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': dependencies: @@ -4407,9 +4425,9 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} - '@hono/node-server@1.19.14(hono@4.12.14)': + '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: - hono: 4.12.14 + hono: 4.12.18 '@humanfs/core@0.19.1': {} @@ -4424,14 +4442,14 @@ snapshots: '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@6.0.11(@types/node@25.6.0)': + '@inquirer/confirm@6.0.12(@types/node@25.6.0)': dependencies: - '@inquirer/core': 11.1.8(@types/node@25.6.0) + '@inquirer/core': 11.1.9(@types/node@25.6.0) '@inquirer/type': 4.0.5(@types/node@25.6.0) optionalDependencies: '@types/node': 25.6.0 - '@inquirer/core@11.1.8(@types/node@25.6.0)': + '@inquirer/core@11.1.9(@types/node@25.6.0)': dependencies: '@inquirer/ansi': 2.0.5 '@inquirer/figures': 2.0.5 @@ -4470,18 +4488,18 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.14) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) + '@hono/node-server': 1.19.14(hono@4.12.18) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 express: 5.2.1 - express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.14 - jose: 6.2.2 + express-rate-limit: 8.5.1(express@5.2.1) + hono: 4.12.18 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -4490,7 +4508,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mswjs/interceptors@0.41.3': + '@mswjs/interceptors@0.41.8': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -5519,7 +5537,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 ansis: 4.2.0 babel-dead-code-elimination: 1.0.12 @@ -5796,9 +5814,9 @@ snapshots: agent-base@7.1.4: {} - ajv-formats@3.0.1(ajv@8.18.0): + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 ajv@6.14.0: dependencies: @@ -5807,10 +5825,10 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -5844,7 +5862,7 @@ snapshots: babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 transitivePeerDependencies: @@ -5856,7 +5874,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.17: {} + baseline-browser-mapping@2.10.27: {} binary-extensions@2.3.0: {} @@ -5888,10 +5906,10 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.17 - caniuse-lite: 1.0.30001787 - electron-to-chromium: 1.5.334 - node-releases: 2.0.37 + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.352 + node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) bundle-name@4.1.0: @@ -5912,7 +5930,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001787: {} + caniuse-lite@1.0.30001792: {} ccount@2.0.1: {} @@ -6070,7 +6088,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.334: {} + electron-to-chromium@1.5.352: {} emoji-regex@10.6.0: {} @@ -6227,11 +6245,11 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.0.8: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 execa@5.1.1: dependencies: @@ -6260,10 +6278,10 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - express-rate-limit@8.3.2(express@5.2.1): + express-rate-limit@8.5.1(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.1.0 + ip-address: 10.2.0 express@5.2.1: dependencies: @@ -6320,7 +6338,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fast-wrap-ansi@0.2.0: dependencies: @@ -6382,10 +6400,10 @@ snapshots: fresh@2.0.0: {} - fs-extra@11.3.4: + fs-extra@11.3.5: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.2.0 + jsonfile: 6.2.1 universalify: 2.0.1 fsevents@2.3.3: @@ -6411,7 +6429,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -6452,11 +6470,11 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.2: {} + graphql@16.14.0: {} has-symbols@1.1.0: {} - hasown@2.0.2: + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -6563,7 +6581,7 @@ snapshots: highlight.js@11.11.1: {} - hono@4.12.14: {} + hono@4.12.18: {} html-parse-stringify@3.0.1: dependencies: @@ -6619,7 +6637,7 @@ snapshots: inline-style-parser@0.2.7: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -6692,7 +6710,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.2: {} + jose@6.2.3: {} jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5): optionalDependencies: @@ -6723,7 +6741,7 @@ snapshots: json5@2.2.3: {} - jsonfile@6.2.0: + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: @@ -7203,24 +7221,24 @@ snapshots: ms@2.1.3: {} - msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3): + msw@2.14.4(@types/node@25.6.0)(typescript@5.9.3): dependencies: - '@inquirer/confirm': 6.0.11(@types/node@25.6.0) - '@mswjs/interceptors': 0.41.3 + '@inquirer/confirm': 6.0.12(@types/node@25.6.0) + '@mswjs/interceptors': 0.41.8 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.2 + graphql: 16.14.0 headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.11.7 + rettime: 0.11.11 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.1 - type-fest: 5.5.0 + type-fest: 5.6.0 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: @@ -7232,6 +7250,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.12: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -7244,7 +7264,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.37: {} + node-releases@2.0.38: {} normalize-path@3.0.0: {} @@ -7392,6 +7412,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + powershell-utils@0.1.0: {} prelude-ls@1.2.1: {} @@ -7650,7 +7676,7 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.11.7: {} + rettime@0.11.11: {} reusify@1.1.0: {} @@ -7740,13 +7766,13 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.3.0(@types/node@25.6.0)(typescript@5.9.3): + shadcn@4.7.0(@types/node@25.6.0)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.3 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.61.0 + '@dotenvx/dotenvx': 1.65.0 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.2 @@ -7757,15 +7783,15 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.4 + fs-extra: 11.3.5 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.13.4(@types/node@25.6.0)(typescript@5.9.3) + msw: 2.14.4(@types/node@25.6.0)(typescript@5.9.3) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 - postcss: 8.5.10 + postcss: 8.5.14 postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 @@ -7907,11 +7933,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tldts-core@7.0.28: {} + tldts-core@7.0.30: {} - tldts@7.0.28: + tldts@7.0.30: dependencies: - tldts-core: 7.0.28 + tldts-core: 7.0.30 to-regex-range@5.0.1: dependencies: @@ -7921,7 +7947,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.28 + tldts: 7.0.30 trim-lines@3.0.1: {} @@ -7957,7 +7983,7 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@5.5.0: + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -8173,7 +8199,7 @@ snapshots: yocto-queue@0.1.0: {} - yocto-spinner@1.1.0: + yocto-spinner@1.2.0: dependencies: yoctocolors: 2.1.2 From f4338d3aaba91f19abe4bc4dc7e09b580a79e285 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:07:23 +0800 Subject: [PATCH 44/71] build(deps): bump @tabler/icons-react in /web/frontend (#2806) Bumps [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) from 3.41.1 to 3.43.0. - [Release notes](https://github.com/tabler/tabler-icons/releases) - [Commits](https://github.com/tabler/tabler-icons/commits/v3.43.0/packages/icons-react) --- updated-dependencies: - dependency-name: "@tabler/icons-react" dependency-version: 3.43.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index db4284906..c45b124fa 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", - "@tabler/icons-react": "^3.40.0", + "@tabler/icons-react": "^3.43.0", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.99.0", "@tanstack/react-router": "^1.169.2", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 4804dea24..7d7c77759 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^5.2.8 version: 5.2.8 '@tabler/icons-react': - specifier: ^3.40.0 - version: 3.41.1(react@19.2.5) + specifier: ^3.43.0 + version: 3.43.0(react@19.2.5) '@tailwindcss/vite': specifier: ^4.2.4 version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.7.0)(tsx@4.21.0)) @@ -1456,13 +1456,13 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@tabler/icons-react@3.41.1': - resolution: {integrity: sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==} + '@tabler/icons-react@3.43.0': + resolution: {integrity: sha512-rXUuCQEeRbEk3lJxs3gwzdtaaITSwc/JUbp+AkqsGff5uBpzZw7eKPDk53xKoKLyjrbj82Ai4GuVG0kO89Jf5g==} peerDependencies: react: '>= 16' - '@tabler/icons@3.41.1': - resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==} + '@tabler/icons@3.43.0': + resolution: {integrity: sha512-qXwS17Op9jqr3Asvu31fejyw8+OnRDKH7oR8nQXyUgW1pI44ET8OKG9kssy+XIvvAIyej6gZdGmviNUn1VMfPw==} '@tailwindcss/node@4.2.4': resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} @@ -5361,12 +5361,12 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@tabler/icons-react@3.41.1(react@19.2.5)': + '@tabler/icons-react@3.43.0(react@19.2.5)': dependencies: - '@tabler/icons': 3.41.1 + '@tabler/icons': 3.43.0 react: 19.2.5 - '@tabler/icons@3.41.1': {} + '@tabler/icons@3.43.0': {} '@tailwindcss/node@4.2.4': dependencies: From 7c8cd7c66a5ec277327010cb016f204daa6d7190 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:07:46 +0800 Subject: [PATCH 45/71] build(deps-dev): bump globals from 17.5.0 to 17.6.0 in /web/frontend (#2807) Bumps [globals](https://github.com/sindresorhus/globals) from 17.5.0 to 17.6.0. - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.5.0...v17.6.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index c45b124fa..0e506f40b 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -61,7 +61,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", + "globals": "^17.6.0", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 7d7c77759..88bc41cc2 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -136,8 +136,8 @@ importers: specifier: ^0.5.2 version: 0.5.2(eslint@10.2.1(jiti@2.7.0)) globals: - specifier: ^17.5.0 - version: 17.5.0 + specifier: ^17.6.0 + version: 17.6.0 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -2498,8 +2498,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@17.5.0: - resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} goober@2.1.18: @@ -6460,7 +6460,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@17.5.0: {} + globals@17.6.0: {} goober@2.1.18(csstype@3.2.3): dependencies: From c2044e5a2c51276714ef6561c7c55e74aad53880 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:08:15 +0800 Subject: [PATCH 46/71] build(deps): bump react-i18next from 17.0.4 to 17.0.6 in /web/frontend (#2808) Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.4 to 17.0.6. - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.4...v17.0.6) --- updated-dependencies: - dependency-name: react-i18next dependency-version: 17.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 0e506f40b..8101fc93c 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -33,7 +33,7 @@ "radix-ui": "^1.4.3", "react": "19.2.5", "react-dom": "19.2.5", - "react-i18next": "^17.0.4", + "react-i18next": "^17.0.6", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "rehype-highlight": "^7.0.2", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 88bc41cc2..bacacb9c3 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -57,8 +57,8 @@ importers: specifier: 19.2.5 version: 19.2.5(react@19.2.5) react-i18next: - specifier: ^17.0.4 - version: 17.0.4(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + specifier: ^17.0.6 + version: 17.0.6(i18next@26.0.8(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) @@ -3419,8 +3419,8 @@ packages: peerDependencies: react: ^19.2.5 - react-i18next@17.0.4: - resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} + react-i18next@17.0.6: + resolution: {integrity: sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==} peerDependencies: i18next: '>= 26.0.1' react: '>= 16.8.0' @@ -7531,7 +7531,7 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-i18next@17.0.4(i18next@26.0.8(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.6(i18next@26.0.8(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 From 3788e9edad4757e7c60ba2341746db4d99e6c001 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:15:44 +0800 Subject: [PATCH 47/71] build(deps): bump i18next from 26.0.8 to 26.0.10 in /web/frontend (#2809) Bumps [i18next](https://github.com/i18next/i18next) from 26.0.8 to 26.0.10. - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.0.8...v26.0.10) --- updated-dependencies: - dependency-name: i18next dependency-version: 26.0.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 8101fc93c..1b6821c33 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -27,7 +27,7 @@ "clsx": "^2.1.1", "dayjs": "^1.11.20", "highlight.js": "^11.11.1", - "i18next": "^26.0.8", + "i18next": "^26.0.10", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.19.1", "radix-ui": "^1.4.3", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index bacacb9c3..8bcd65944 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: specifier: ^11.11.1 version: 11.11.1 i18next: - specifier: ^26.0.8 - version: 26.0.8(typescript@5.9.3) + specifier: ^26.0.10 + version: 26.0.10(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.2.1 version: 8.2.1 @@ -58,7 +58,7 @@ importers: version: 19.2.5(react@19.2.5) react-i18next: specifier: ^17.0.6 - version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + version: 17.0.6(i18next@26.0.10(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) @@ -2601,8 +2601,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next@26.0.8: - resolution: {integrity: sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==} + i18next@26.0.10: + resolution: {integrity: sha512-k3yGPAlWR2RdMYoVXJoDZDT87qeHIWKH7gVksdZMpRty7QX/D9QZeYGvN08KGbKHke9wn01eYT+EEsrqX/YTlw==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -6614,7 +6614,7 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next@26.0.8(typescript@5.9.3): + i18next@26.0.10(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -7531,11 +7531,11 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-i18next@17.0.6(i18next@26.0.8(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.6(i18next@26.0.10(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.8(typescript@5.9.3) + i18next: 26.0.10(typescript@5.9.3) react: 19.2.5 use-sync-external-store: 1.6.0(react@19.2.5) optionalDependencies: From 6d7d1b09096a7da43dfa759950fbcf689716f297 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Fri, 8 May 2026 14:05:58 +0900 Subject: [PATCH 48/71] fix(line): capture QuoteToken for all message types and handle location - Store QuoteToken for image, video, and sticker messages (not just text) - Add webhook.LocationMessageContent case to forward as [location] placeholder Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/channels/line/line.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 87eecd014..e45c1e2e3 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -231,6 +231,10 @@ func (c *LINEChannel) processEvent(event webhook.EventInterface) { } case webhook.ImageMessageContent: messageID = msg.Id + if msg.QuoteToken != "" { + quoteToken = msg.QuoteToken + c.quoteTokens.Store(chatID, msg.QuoteToken) + } if localPath := c.downloadContent(msg.Id, "image.jpg"); localPath != "" { scope := channels.BuildMediaScope("line", chatID, msg.Id) mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg", scope)) @@ -245,6 +249,10 @@ func (c *LINEChannel) processEvent(event webhook.EventInterface) { } case webhook.VideoMessageContent: messageID = msg.Id + if msg.QuoteToken != "" { + quoteToken = msg.QuoteToken + c.quoteTokens.Store(chatID, msg.QuoteToken) + } if localPath := c.downloadContent(msg.Id, "video.mp4"); localPath != "" { scope := channels.BuildMediaScope("line", chatID, msg.Id) mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4", scope)) @@ -253,8 +261,18 @@ func (c *LINEChannel) processEvent(event webhook.EventInterface) { case webhook.FileMessageContent: messageID = msg.Id content = "[file]" + case webhook.LocationMessageContent: + messageID = msg.Id + content = "[location]" + if msg.Title != "" { + content = fmt.Sprintf("[location: %s]", msg.Title) + } case webhook.StickerMessageContent: messageID = msg.Id + if msg.QuoteToken != "" { + quoteToken = msg.QuoteToken + c.quoteTokens.Store(chatID, msg.QuoteToken) + } content = "[sticker]" default: logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{ From bacb9aba7cfa2c0afb9c013a9155e3a314a0e9de Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Fri, 8 May 2026 14:11:15 +0900 Subject: [PATCH 49/71] fix(line): close response body on successful SendMedia calls Always route through classifySDKError to ensure resp.Body is closed even when the API call succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/channels/line/line.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index e45c1e2e3..d4d34211d 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -532,8 +532,8 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag To: msg.ChatID, Messages: []messaging_api.MessageInterface{&textMsg}, }, "") - if err != nil { - return nil, classifySDKError(resp, err) + if sdkErr := classifySDKError(resp, err); sdkErr != nil { + return nil, sdkErr } } From 610e9e3fe8c012ff2a66dba657697b446e759d51 Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Thu, 7 May 2026 21:06:18 -0700 Subject: [PATCH 50/71] fix(agent): dismiss session tool feedback on skipped outbound --- pkg/agent/agent_outbound.go | 10 +++++++ pkg/agent/agent_test.go | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go index 1728f6f79..f4a01adfd 100644 --- a/pkg/agent/agent_outbound.go +++ b/pkg/agent/agent_outbound.go @@ -56,6 +56,16 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI } if alreadySentToSameChat { + if al.channelManager != nil && channel != "" && chatID != "" { + dismissCtx, dismissCancel := context.WithTimeout(ctx, 5*time.Second) + al.channelManager.DismissToolFeedback( + dismissCtx, + channel, + chatID, + nil, + ) + dismissCancel() + } logger.DebugCF( "agent", "Skipped outbound (message tool already sent to same chat)", diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index a75919912..cf693930d 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -57,6 +57,28 @@ func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM return nil, nil } +type recordingChannelManager struct { + dismissed []string +} + +func (m *recordingChannelManager) GetChannel(name string) (channels.Channel, bool) { return nil, false } +func (m *recordingChannelManager) GetEnabledChannels() []string { return nil } +func (m *recordingChannelManager) InvokeTypingStop(channel, chatID string) {} +func (m *recordingChannelManager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + return nil +} +func (m *recordingChannelManager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return nil +} +func (m *recordingChannelManager) SendPlaceholder(ctx context.Context, channel, chatID string) bool { + return false +} +func (m *recordingChannelManager) DismissToolFeedback( + ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext, +) { + m.dismissed = append(m.dismissed, fmt.Sprintf("%s:%s", channel, chatID)) +} + func newStartedTestChannelManager( t *testing.T, msgBus *bus.MessageBus, @@ -214,6 +236,41 @@ func TestNewAgentLoop_DoesNotRegisterWebSearchTool_WhenNoReadyProviders(t *testi } } +func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent(t *testing.T) { + al, _, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + + cm := &recordingChannelManager{} + al.channelManager = cm + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + mt := tools.NewMessageTool() + mt.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error { + return nil + }) + defaultAgent.Tools.Register(mt) + + result := mt.Execute( + tools.WithToolSessionContext(context.Background(), "main", "session-1", nil), + map[string]any{ + "content": "ack", + "channel": "telegram", + "chat_id": "-100123", + }, + ) + if result == nil || result.IsError { + t.Fatalf("message tool execute failed: %+v", result) + } + al.PublishResponseIfNeeded(context.Background(), "telegram", "-100123", "session-1", "final reply") + + if got := cm.dismissed; len(got) != 1 || got[0] != "telegram:-100123" { + t.Fatalf("dismissed = %v, want [telegram:-100123]", got) + } +} + func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { From f062cb41d70a5bfb05b06a5bbcc819b1bf129ca5 Mon Sep 17 00:00:00 2001 From: hehaijunandhenry Date: Fri, 8 May 2026 14:48:43 +0800 Subject: [PATCH 51/71] 1 --- pkg/channels/manager_channel.go | 5 +++++ pkg/channels/mqtt/mqtt.go | 21 +++++++++++++++++---- pkg/config/config.go | 16 ++++++++-------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index 1f5978e7d..a5e9a49be 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -102,6 +102,11 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) { } } value["webhooks"] = webhooks + case "mqtt": + if settings, ok := v.(*config.MQTTSettings); ok { + value["username"] = settings.Username.String() + value["password"] = settings.Password.String() + } } } diff --git a/pkg/channels/mqtt/mqtt.go b/pkg/channels/mqtt/mqtt.go index d183fcc3e..39956ed9c 100644 --- a/pkg/channels/mqtt/mqtt.go +++ b/pkg/channels/mqtt/mqtt.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "strings" + "sync" "time" pahomqtt "github.com/eclipse/paho.mqtt.golang" @@ -86,9 +87,13 @@ func (c *MQTTChannel) Start(ctx context.Context) error { opts.SetPassword(c.cfg.Password.String()) } + firstSubscribe := make(chan error, 1) + var once sync.Once + opts.SetOnConnectHandler(func(client pahomqtt.Client) { logger.InfoC("mqtt", "MQTT connected, subscribing to inbound topic") - c.subscribe(client) + err := c.subscribe(client) + once.Do(func() { firstSubscribe <- err }) }) opts.SetConnectionLostHandler(func(_ pahomqtt.Client, err error) { @@ -98,12 +103,19 @@ func (c *MQTTChannel) Start(ctx context.Context) error { client := pahomqtt.NewClient(opts) token := client.Connect() if !token.WaitTimeout(10 * time.Second) { + client.Disconnect(250) return fmt.Errorf("mqtt connect timed out after 10s (broker: %s)", c.cfg.Broker) } if err := token.Error(); err != nil { + client.Disconnect(250) return fmt.Errorf("mqtt connect failed: %w", err) } + if err := <-firstSubscribe; err != nil { + client.Disconnect(250) + return fmt.Errorf("mqtt subscribe failed: %w", err) + } + c.client = client c.SetRunning(true) @@ -144,7 +156,7 @@ func (c *MQTTChannel) clientIDFromTopic(topic string) (string, bool) { } // subscribe subscribes to the inbound topic for this agent. -func (c *MQTTChannel) subscribe(client pahomqtt.Client) { +func (c *MQTTChannel) subscribe(client pahomqtt.Client) error { topic := fmt.Sprintf("%s/%s/+/request", c.topicPrefix(), c.cfg.AgentID) token := client.Subscribe(topic, c.qos, func(_ pahomqtt.Client, msg pahomqtt.Message) { c.handleInbound(msg) @@ -155,9 +167,10 @@ func (c *MQTTChannel) subscribe(client pahomqtt.Client) { "topic": topic, "error": err.Error(), }) - } else { - logger.InfoCF("mqtt", "Subscribed to inbound topic", map[string]any{"topic": topic}) + return err } + logger.InfoCF("mqtt", "Subscribed to inbound topic", map[string]any{"topic": topic}) + return nil } // handleInbound processes an inbound MQTT message. diff --git a/pkg/config/config.go b/pkg/config/config.go index c2f400e23..5757f53cf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -517,14 +517,14 @@ type TeamsWebhookTarget struct { } type MQTTSettings struct { - Broker string `json:"broker" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_BROKER"` - AgentID string `json:"agent_id" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_AGENT_ID"` - TopicPrefix string `json:"topic_prefix,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX"` - Username SecureString `json:"username,omitzero" yaml:"username,omitempty" env:"PICOCLAW_CHANNELS_MQTT_USERNAME"` - Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_MQTT_PASSWORD"` - ClientID string `json:"client_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_CLIENT_ID"` - KeepAlive int `json:"keep_alive,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE"` - QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"` + Broker string `json:"broker" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_BROKER"` + AgentID string `json:"agent_id" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_AGENT_ID"` + TopicPrefix string `json:"topic_prefix,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX"` + Username SecureString `json:"username,omitzero" yaml:"username,omitempty" env:"PICOCLAW_CHANNELS_MQTT_USERNAME"` + Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_MQTT_PASSWORD"` + ClientID string `json:"client_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_CLIENT_ID"` + KeepAlive int `json:"keep_alive,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE"` + QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"` } type HeartbeatConfig struct { From d5c8bfffbcdd3e0f0c2c5c7b5436afc4b8c7ede4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=85=89=E6=98=A5?= Date: Fri, 8 May 2026 15:14:33 +0800 Subject: [PATCH 52/71] fix(docs): correct Baidu Search free tier from 1000/day to 1500/month (#2784) (#2825) --- README.md | 2 +- docs/project/README.fr.md | 2 +- docs/project/README.id.md | 2 +- docs/project/README.it.md | 2 +- docs/project/README.ja.md | 2 +- docs/project/README.ko.md | 2 +- docs/project/README.ms.md | 2 +- docs/project/README.pt-br.md | 2 +- docs/project/README.vi.md | 2 +- docs/project/README.zh.md | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 30ac67d8f..639b53ce6 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too | Search Engine | API Key | Free Tier | Link | |--------------|---------|-----------|------| | DuckDuckGo | Not needed | Unlimited | Built-in fallback | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized | | [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents | | [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private | | [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search | diff --git a/docs/project/README.fr.md b/docs/project/README.fr.md index b02067d2a..ce82eb2ad 100644 --- a/docs/project/README.fr.md +++ b/docs/project/README.fr.md @@ -479,7 +479,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations | Moteur de recherche | Clé API | Niveau gratuit | Lien | |--------------------|---------|----------------|------| | DuckDuckGo | Non requise | Illimité | Fallback intégré | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1000 requêtes/jour | IA, optimisé pour le chinois | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1500 requêtes/mois (allocation journalière) | IA, optimisé pour le chinois | | [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA | | [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé | | [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA | diff --git a/docs/project/README.id.md b/docs/project/README.id.md index 49c64e74c..b03be36d7 100644 --- a/docs/project/README.id.md +++ b/docs/project/README.id.md @@ -474,7 +474,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t | Mesin Pencari | API Key | Tier Gratis | Tautan | |--------------|---------|-------------|--------| | DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 kueri/bulan (alokasi harian) | Bertenaga AI, dioptimalkan untuk bahasa Mandarin | | [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent | | [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat | | [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI | diff --git a/docs/project/README.it.md b/docs/project/README.it.md index 0cf6cf8db..3a1a0460c 100644 --- a/docs/project/README.it.md +++ b/docs/project/README.it.md @@ -474,7 +474,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in | Motore di Ricerca | API Key | Piano Gratuito | Link | |-------------------|---------|----------------|------| | DuckDuckGo | Non necessaria | Illimitato | Fallback integrato | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1500 query/mese (allocazione giornaliera) | IA, ottimizzato per il cinese | | [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent | | [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato | | [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA | diff --git a/docs/project/README.ja.md b/docs/project/README.ja.md index 6e3060688..591faa13a 100644 --- a/docs/project/README.ja.md +++ b/docs/project/README.ja.md @@ -475,7 +475,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to | 検索エンジン | API キー | 無料枠 | リンク | |------------|---------|--------|-------| | DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1000 クエリ/日 | AI 搭載、中国語に最適化 | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1500 クエリ/月(日次割り当て) | AI 搭載、中国語に最適化 | | [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 | | [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート | | [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 | diff --git a/docs/project/README.ko.md b/docs/project/README.ko.md index dfefa67fe..f47cda8a1 100644 --- a/docs/project/README.ko.md +++ b/docs/project/README.ko.md @@ -480,7 +480,7 @@ PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있 | 검색 엔진 | API Key | 무료 제공량 | 링크 | |-----------|---------|-------------|------| | DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 하루 1000회 쿼리 | AI 기반, 중국 시장 최적화 | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 월 1500회 쿼리 (일할 할당) | AI 기반, 중국 시장 최적화 | | [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 | | [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 | | [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 | diff --git a/docs/project/README.ms.md b/docs/project/README.ms.md index 73c428f11..daef86d9e 100644 --- a/docs/project/README.ms.md +++ b/docs/project/README.ms.md @@ -474,7 +474,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da | Enjin Carian | Kunci API | Peringkat Percuma | Pautan | |-------------|-----------|-------------------|--------| | DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 pertanyaan/hari | Dikuasai AI, dioptimumkan untuk China | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 pertanyaan/bulan (peruntukan harian) | Dikuasai AI, dioptimumkan untuk China | | [Tavily](https://tavily.com) | Diperlukan | 1000 pertanyaan/bulan | Dioptimumkan untuk AI Agent | | [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi | | [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI | diff --git a/docs/project/README.pt-br.md b/docs/project/README.pt-br.md index 74cb967de..6ba80cf11 100644 --- a/docs/project/README.pt-br.md +++ b/docs/project/README.pt-br.md @@ -475,7 +475,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config | Motor de Busca | API Key | Nível Gratuito | Link | |----------------|---------|----------------|------| | DuckDuckGo | Não necessária | Ilimitado | Fallback integrado | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1500 consultas/mês (alocação diária) | IA, otimizado para chinês | | [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents | | [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado | | [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA | diff --git a/docs/project/README.vi.md b/docs/project/README.vi.md index 743069021..d319ef6c4 100644 --- a/docs/project/README.vi.md +++ b/docs/project/README.vi.md @@ -475,7 +475,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C | Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết | |------------------|---------|--------------|----------| | DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn | -| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung | +| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1500 truy vấn/tháng (phân bổ hàng ngày) | AI, tối ưu cho tiếng Trung | | [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent | | [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư | | [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI | diff --git a/docs/project/README.zh.md b/docs/project/README.zh.md index 253bb84ed..77f5010b7 100644 --- a/docs/project/README.zh.md +++ b/docs/project/README.zh.md @@ -475,7 +475,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置: | 搜索引擎 | API Key | 免费额度 | 链接 | |---------|---------|---------|------| -| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1000 次/天 | AI 搜索,国内首选 | +| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1500 次/月(按天发放) | AI 搜索,国内首选 | | [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 | | [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 | | DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) | From 871892ff15b2d3e627ea0e73d3de5c1ac97e6bbb Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Fri, 8 May 2026 09:18:14 +0200 Subject: [PATCH 53/71] fix(tools): exempt MCP discovery tools from agent allowlists --- pkg/tools/registry.go | 7 +++++++ pkg/tools/registry_test.go | 19 +++++++++++++++++++ pkg/tools/search_tool.go | 15 +++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index f8f0aa3fd..e90d683bb 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -172,6 +172,13 @@ func (r *ToolRegistry) toolAllowedLocked(name string) bool { if r.allowlist == nil { return true } + if isToolDiscoveryToolName(name) { + // Discovery tools are part of the MCP control plane: they must remain + // available whenever configured so deferred MCP tools can still be + // unlocked. Per-agent allowlists still apply to the hidden MCP tools + // themselves during RegisterHidden. + return true + } _, ok := r.allowlist[strings.ToLower(strings.TrimSpace(name))] return ok } diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index f75a321f2..ee63586ab 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -130,6 +130,25 @@ func TestToolRegistry_AllowlistFiltersRegistrations(t *testing.T) { } } +func TestToolRegistry_AllowlistStillAllowsDiscoveryTools(t *testing.T) { + r := NewToolRegistry() + r.SetAllowlist([]string{"mcp_github_search"}) + + r.Register(newMockTool(BM25SearchToolName, "discover hidden tools")) + r.Register(newMockTool(RegexSearchToolName, "discover hidden tools via regex")) + r.Register(newMockTool("blocked_tool", "blocked")) + + if _, ok := r.Get(BM25SearchToolName); !ok { + t.Fatal("expected BM25 discovery tool to bypass allowlist filtering") + } + if _, ok := r.Get(RegexSearchToolName); !ok { + t.Fatal("expected regex discovery tool to bypass allowlist filtering") + } + if _, ok := r.Get("blocked_tool"); ok { + t.Fatal("blocked_tool should not be registered") + } +} + func TestToolRegistry_HasRegisteredIncludesHiddenTools(t *testing.T) { r := NewToolRegistry() r.SetAllowlist([]string{"visible", "hidden"}) diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go index c5884c9de..511b81a03 100644 --- a/pkg/tools/search_tool.go +++ b/pkg/tools/search_tool.go @@ -14,6 +14,8 @@ import ( const ( MaxRegexPatternLength = 200 + RegexSearchToolName = "tool_search_tool_regex" + BM25SearchToolName = "tool_search_tool_bm25" ) type RegexSearchTool struct { @@ -27,7 +29,7 @@ func NewRegexSearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *RegexSe } func (t *RegexSearchTool) Name() string { - return "tool_search_tool_regex" + return RegexSearchToolName } func (t *RegexSearchTool) Description() string { @@ -96,7 +98,7 @@ func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25Sear } func (t *BM25SearchTool) Name() string { - return "tool_search_tool_bm25" + return BM25SearchToolName } func (t *BM25SearchTool) Description() string { @@ -294,6 +296,15 @@ func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine { return cached } +func isToolDiscoveryToolName(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case BM25SearchToolName, RegexSearchToolName: + return true + default: + return false + } +} + // SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine. // This non-cached variant rebuilds the engine on every call. Used by tests // and any code that doesn't hold a BM25SearchTool instance. From 2287de521e4709f1f6e40e58b5048fa56874f323 Mon Sep 17 00:00:00 2001 From: hehaijunandhenry Date: Fri, 8 May 2026 15:49:28 +0800 Subject: [PATCH 54/71] Linter fixed --- pkg/config/config.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 5757f53cf..c9d90e0f8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -517,14 +517,14 @@ type TeamsWebhookTarget struct { } type MQTTSettings struct { - Broker string `json:"broker" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_BROKER"` - AgentID string `json:"agent_id" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_AGENT_ID"` - TopicPrefix string `json:"topic_prefix,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX"` - Username SecureString `json:"username,omitzero" yaml:"username,omitempty" env:"PICOCLAW_CHANNELS_MQTT_USERNAME"` - Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_MQTT_PASSWORD"` - ClientID string `json:"client_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_CLIENT_ID"` - KeepAlive int `json:"keep_alive,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE"` - QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"` + Broker string `json:"broker" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_BROKER"` + AgentID string `json:"agent_id" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_AGENT_ID"` + TopicPrefix string `json:"topic_prefix,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX"` + Username SecureString `json:"username,omitzero" yaml:"username,omitempty" env:"PICOCLAW_CHANNELS_MQTT_USERNAME"` + Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_MQTT_PASSWORD"` + ClientID string `json:"client_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_CLIENT_ID"` + KeepAlive int `json:"keep_alive,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE"` + QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"` } type HeartbeatConfig struct { From 569939a7b37e15fd92a5549a44919f7c36308f06 Mon Sep 17 00:00:00 2001 From: hehaijunandhenry Date: Fri, 8 May 2026 17:21:25 +0800 Subject: [PATCH 55/71] Fix stop_mqtt_channel --- pkg/channels/mqtt/mqtt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/mqtt/mqtt.go b/pkg/channels/mqtt/mqtt.go index 39956ed9c..c34bc79bf 100644 --- a/pkg/channels/mqtt/mqtt.go +++ b/pkg/channels/mqtt/mqtt.go @@ -213,7 +213,7 @@ func (c *MQTTChannel) Stop(_ context.Context) error { logger.InfoC("mqtt", "Stopping MQTT channel") c.SetRunning(false) - if c.client != nil && c.client.IsConnected() { + if c.client != nil { c.client.Disconnect(500) } From ffa184d18328a77ed1d321374052f133dd718635 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Fri, 8 May 2026 13:43:21 +0200 Subject: [PATCH 56/71] fix(agent): resolve primary provider from frontmatter model --- pkg/agent/instance.go | 42 +++++++++++++++++++++++++++++++ pkg/agent/instance_test.go | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index ac2955334..63aac150b 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -150,6 +150,7 @@ func NewAgentInstance( subagents = agentCfg.Subagents skillsFilter = resolveAgentSkillsFilter(agentCfg, definition) } + provider = resolvePrimaryProviderForAgent(cfg, workspace, agentID, model, provider) warnOnUnknownAgentMCPServerDeclarations(agentID, workspace, cfg, definition) maxIter := defaults.MaxToolIterations @@ -305,6 +306,47 @@ func populateCandidateProvidersFromNames( } } +// resolvePrimaryProviderForAgent resolves a dedicated provider for the active +// primary model when the model points at a model_list entry. This keeps the +// agent's single-candidate path aligned with the selected model's own +// provider/api_base/api_key instead of inheriting the process default provider. +func resolvePrimaryProviderForAgent( + cfg *config.Config, + workspace string, + agentID string, + model string, + fallback providers.LLMProvider, +) providers.LLMProvider { + model = strings.TrimSpace(model) + if cfg == nil || model == "" { + return fallback + } + + modelCfg := lookupModelConfigByRef(cfg, model) + if modelCfg == nil { + return fallback + } + clone := *modelCfg + if clone.Workspace == "" { + clone.Workspace = workspace + } + + resolvedProvider, _, err := providers.CreateProviderFromConfig(&clone) + if err != nil { + logger.WarnCF("agent", "Primary model provider init failed; using injected provider", + map[string]any{ + "agent_id": agentID, + "model": model, + "error": err.Error(), + }) + return fallback + } + if resolvedProvider == nil { + return fallback + } + return resolvedProvider +} + // resolveAgentWorkspace determines the workspace directory for an agent. func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 2b144914e..97a5dde67 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -666,6 +666,57 @@ Use frontmatter identity. } } +func TestNewAgentInstance_UsesResolvedProviderForFrontmatterPrimaryModel(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +model: claude-frontmatter +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + Provider: "openai", + ModelName: "default-model", + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "claude-frontmatter", + Model: "anthropic/claude-3-7-sonnet", + APIKeys: config.SimpleSecureStrings("test-anthropic-key"), + Workspace: workspace, + }, + }, + } + + defaultProvider := &mockProvider{} + agent := NewAgentInstance(&config.AgentConfig{ + ID: "research", + Workspace: workspace, + }, &cfg.Agents.Defaults, cfg, defaultProvider) + + if agent.Model != "claude-frontmatter" { + t.Fatalf("agent.Model = %q, want %q", agent.Model, "claude-frontmatter") + } + if len(agent.Candidates) != 1 { + t.Fatalf("len(agent.Candidates) = %d, want 1", len(agent.Candidates)) + } + if got := agent.Candidates[0].Provider; got != "anthropic" { + t.Fatalf("primary candidate provider = %q, want %q", got, "anthropic") + } + if got := agent.Candidates[0].Model; got != "claude-3-7-sonnet" { + t.Fatalf("primary candidate model = %q, want %q", got, "claude-3-7-sonnet") + } + if agent.Provider == defaultProvider { + t.Fatal("expected primary provider to be resolved from model_list instead of using injected default provider") + } +} + func TestNewAgentInstance_InvalidFrontmatterFailsClosedForToolsAndMCPServers(t *testing.T) { workspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- From c6a09a35e23ded5bcba79b5af3445f6e04f8b0fe Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Fri, 8 May 2026 13:48:47 +0200 Subject: [PATCH 57/71] fix(agent): suppress MCP discovery when no servers are selectable --- pkg/agent/agent_mcp.go | 18 ++++++++ pkg/agent/agent_mcp_test.go | 65 +++++++++++++++++++++++++++ pkg/agent/instance.go | 2 +- pkg/agent/instance_test.go | 90 +++++++++++++++++++++++++++++++++++++ pkg/agent/tool_allowlist.go | 10 ++++- 5 files changed, 183 insertions(+), 2 deletions(-) diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index 3d569b2bd..e8cdf81c8 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -250,6 +250,9 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { if !ok { continue } + if !agentHasDiscoverableMCPServers(al.cfg, agent.MCPServerAllowlist) { + continue + } if useRegex { agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) @@ -334,6 +337,21 @@ func filterMCPConfigServers( return filtered } +func agentHasDiscoverableMCPServers(cfg *config.Config, allowed map[string]struct{}) bool { + if cfg == nil || !cfg.Tools.MCP.Enabled || !cfg.Tools.MCP.Discovery.Enabled { + return false + } + + filtered := filterMCPConfigServers(cfg.Tools.MCP, allowed) + for _, serverCfg := range filtered.Servers { + if serverCfg.Enabled && serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg) { + return true + } + } + + return false +} + // serverIsDeferred reports whether an MCP server's tools should be registered // as hidden (deferred/discovery mode). // diff --git a/pkg/agent/agent_mcp_test.go b/pkg/agent/agent_mcp_test.go index 7c8a4cd28..f85861146 100644 --- a/pkg/agent/agent_mcp_test.go +++ b/pkg/agent/agent_mcp_test.go @@ -204,6 +204,71 @@ func TestFilterMCPConfigServersCaseInsensitivePreservesOriginalKeys(t *testing.T } } +func TestAgentHasDiscoverableMCPServers(t *testing.T) { + deferredFalse := false + cfg := &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Discovery: config.ToolDiscoveryConfig{ + Enabled: true, + UseBM25: true, + UseRegex: false, + }, + Servers: map[string]config.MCPServerConfig{ + "github": {Enabled: true}, + "filesystem": {Enabled: true, Deferred: &deferredFalse}, + }, + }, + }, + } + + tests := []struct { + name string + allowed map[string]struct{} + want bool + }{ + { + name: "nil allowlist includes discoverable enabled server", + want: true, + }, + { + name: "empty allowlist denies all servers", + allowed: map[string]struct{}{}, + want: false, + }, + { + name: "selected server discoverable", + allowed: map[string]struct{}{ + "github": {}, + }, + want: true, + }, + { + name: "selected server opted out of discovery", + allowed: map[string]struct{}{ + "filesystem": {}, + }, + want: false, + }, + { + name: "unknown allowlist server matches nothing", + allowed: map[string]struct{}{ + "slack": {}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := agentHasDiscoverableMCPServers(cfg, tt.allowed); got != tt.want { + t.Fatalf("agentHasDiscoverableMCPServers() = %v, want %v", got, tt.want) + } + }) + } +} + func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) { al, cfg, _, _, cleanup := newTestAgentLoop(t) defer cleanup() diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 63aac150b..4ed713035 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -128,7 +128,7 @@ func NewAgentInstance( sessionsDir := filepath.Join(workspace, "sessions") sessions := initSessionStore(sessionsDir) - mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled + mcpDiscoveryActive := agentHasDiscoverableMCPServers(cfg, agentMCPServerAllowlist) contextBuilder := NewContextBuilder(workspace). WithToolDiscovery( mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 97a5dde67..76e1b7f2d 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -10,6 +10,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" ) func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { @@ -717,6 +718,95 @@ model: claude-frontmatter } } +func TestNewAgentInstance_SuppressesToolDiscoveryPromptWhenNoMCPServersSelected(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +mcpServers: [] +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "default-model", + }, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Discovery: config.ToolDiscoveryConfig{ + Enabled: true, + UseBM25: true, + UseRegex: false, + }, + Servers: map[string]config.MCPServerConfig{ + "github": {Enabled: true}, + }, + }, + }, + } + + agent := NewAgentInstance(&config.AgentConfig{ + ID: "research", + Workspace: workspace, + }, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + if agent.AllowsMCPServer("github") { + t.Fatal("expected empty mcpServers allowlist to deny all servers") + } + messages := agent.ContextBuilder.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + if prompt := messages[0].Content; strings.Contains(prompt, tools.BM25SearchToolName) { + t.Fatalf("expected no tool discovery prompt when no MCP servers are selected, got %q", prompt) + } +} + +func TestNewAgentInstance_IncludesToolDiscoveryPromptWhenDiscoverableMCPServerSelected(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +mcpServers: [github] +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "default-model", + }, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Discovery: config.ToolDiscoveryConfig{ + Enabled: true, + UseBM25: true, + UseRegex: false, + }, + Servers: map[string]config.MCPServerConfig{ + "github": {Enabled: true}, + }, + }, + }, + } + + agent := NewAgentInstance(&config.AgentConfig{ + ID: "research", + Workspace: workspace, + }, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + messages := agent.ContextBuilder.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"}) + if prompt := messages[0].Content; !strings.Contains(prompt, tools.BM25SearchToolName) { + t.Fatalf("expected tool discovery prompt when a discoverable MCP server is selected, got %q", prompt) + } +} + func TestNewAgentInstance_InvalidFrontmatterFailsClosedForToolsAndMCPServers(t *testing.T) { workspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index 7a020c82c..ad7394c7d 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -164,7 +164,7 @@ func resolveAgentMCPServerAllowlist(definition AgentContextDefinition) map[strin if frontmatterParseFailed(definition) { return map[string]struct{}{} } - if definition.Agent == nil || definition.Agent.Frontmatter.MCPServers == nil { + if definition.Agent == nil || !frontmatterDeclaresField(definition, "mcpServers") { return nil } @@ -180,6 +180,14 @@ func resolveAgentMCPServerAllowlist(definition AgentContextDefinition) map[strin return allowlist } +func frontmatterDeclaresField(definition AgentContextDefinition, field string) bool { + if definition.Agent == nil || definition.Agent.Frontmatter.Fields == nil { + return false + } + _, ok := definition.Agent.Frontmatter.Fields[field] + return ok +} + func frontmatterParseFailed(definition AgentContextDefinition) bool { if definition.Agent == nil { return false From a3edbcd05e37ba7c55fba2a81b104cd22f5afabb Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Fri, 8 May 2026 10:33:16 -0700 Subject: [PATCH 58/71] test(agent): satisfy lint for tool feedback cleanup --- pkg/agent/agent_test.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index cf693930d..7a869ec94 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -61,18 +61,28 @@ type recordingChannelManager struct { dismissed []string } -func (m *recordingChannelManager) GetChannel(name string) (channels.Channel, bool) { return nil, false } -func (m *recordingChannelManager) GetEnabledChannels() []string { return nil } -func (m *recordingChannelManager) InvokeTypingStop(channel, chatID string) {} +func (m *recordingChannelManager) GetChannel(name string) (channels.Channel, bool) { + return nil, false +} + +func (m *recordingChannelManager) GetEnabledChannels() []string { + return nil +} + +func (m *recordingChannelManager) InvokeTypingStop(channel, chatID string) {} + func (m *recordingChannelManager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { return nil } + func (m *recordingChannelManager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { return nil } + func (m *recordingChannelManager) SendPlaceholder(ctx context.Context, channel, chatID string) bool { return false } + func (m *recordingChannelManager) DismissToolFeedback( ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext, ) { @@ -237,8 +247,11 @@ func TestNewAgentLoop_DoesNotRegisterWebSearchTool_WhenNoReadyProviders(t *testi } func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent(t *testing.T) { - al, _, _, _, cleanup := newTestAgentLoop(t) + al, msgBus, provider, sessions, cleanup := newTestAgentLoop(t) defer cleanup() + _ = msgBus + _ = provider + _ = sessions cm := &recordingChannelManager{} al.channelManager = cm From 148583e7bb211349075b788c36773da328f8f636 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Fri, 8 May 2026 22:23:50 +0200 Subject: [PATCH 59/71] fix(agent): hide discovery when spawn is unavailable --- docs/guides/configuration.md | 4 +- pkg/agent/discovery.go | 8 ++- pkg/agent/discovery_test.go | 124 ++++++++++++++++++++++++++++++++--- pkg/agent/registry.go | 8 +++ 4 files changed, 132 insertions(+), 12 deletions(-) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 4cbe9dd82..3bec847ba 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -238,7 +238,7 @@ Notes: ### Agent Discovery (Automatic) -When an agent has spawnable peers, PicoClaw injects a structured agent registry into that agent's system prompt on every turn. No extra `list_agents` tool call is required. +When an agent has spawnable peers and can call `spawn`, PicoClaw injects a structured agent registry into that agent's system prompt on every turn. No extra `list_agents` tool call is required. This registry is intended to make delegation concrete and reliable, especially when using `spawn` with a target `agent_id`. @@ -252,7 +252,7 @@ Each entry includes: Important behavior: -- The discovery section includes only peer agents the current agent is permitted to spawn via `subagents.allow_agents`. +- The discovery section appears only when the current agent has the `spawn` tool and includes only peer agents it is permitted to spawn via `subagents.allow_agents`. - The current agent and non-spawnable peers are omitted, so the model does not plan against unavailable agents. - Discovery is intentionally lightweight. It gives the model only the identity it needs to choose a peer: `id`, `name`, and `description`. - `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. Those permissions also gate discovery visibility. diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index 8c1c5bb82..d2f63bc1f 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -60,8 +60,9 @@ func (r *AgentRegistry) ListAgents(workspace string) []AgentDescriptor { return descriptors } -// ListSpawnableAgents returns descriptors only for agents the current agent is -// allowed to spawn. Restricted peers are intentionally omitted from discovery. +// ListSpawnableAgents returns descriptors only when the current agent can call +// spawn, and only for peers it is allowed to spawn. Restricted peers are +// intentionally omitted from discovery. func (r *AgentRegistry) ListSpawnableAgents(agentID string) []AgentDescriptor { r.mu.RLock() defer r.mu.RUnlock() @@ -71,6 +72,9 @@ func (r *AgentRegistry) ListSpawnableAgents(agentID string) []AgentDescriptor { if !ok || parent == nil { return nil } + if !agentHasSpawnTool(parent) { + return nil + } ids := make([]string, 0, len(r.agents)) for id := range r.agents { diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go index bceee54d7..f31a113d8 100644 --- a/pkg/agent/discovery_test.go +++ b/pkg/agent/discovery_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) @@ -79,9 +80,13 @@ func TestAgentRegistry_ListSpawnableAgentsRespectsPermissions(t *testing.T) { {ID: "child2"}, {ID: "restricted"}, }) + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) - descriptors := registry.ListSpawnableAgents("parent") + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + descriptors := al.GetRegistry().ListSpawnableAgents("parent") if len(descriptors) != 2 { t.Fatalf("expected 2 spawnable descriptors, got %d: %+v", len(descriptors), descriptors) } @@ -90,6 +95,27 @@ func TestAgentRegistry_ListSpawnableAgentsRespectsPermissions(t *testing.T) { } } +func TestAgentRegistry_ListSpawnableAgentsRequiresSpawnTool(t *testing.T) { + cfg := testCfg([]config.AgentConfig{ + { + ID: "parent", + Default: true, + Subagents: &config.SubagentsConfig{ + AllowAgents: []string{"child"}, + }, + }, + {ID: "child"}, + }) + cfg.Tools.Subagent.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + if descriptors := al.GetRegistry().ListSpawnableAgents("parent"); len(descriptors) != 0 { + t.Fatalf("expected no spawnable descriptors without spawn tool, got %+v", descriptors) + } +} + func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) { mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- @@ -140,9 +166,13 @@ Handle restricted work. }) cfg.Tools.ReadFile.Enabled = true cfg.Tools.WriteFile.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) - mainAgent, ok := registry.GetAgent("main") + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + mainAgent, ok := al.GetRegistry().GetAgent("main") if !ok || mainAgent == nil { t.Fatal("expected main agent") } @@ -211,9 +241,13 @@ Investigate deeply. {ID: "research", Workspace: researchWorkspace}, }) cfg.Tools.ReadFile.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) - mainAgent, ok := registry.GetAgent("main") + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + mainAgent, ok := al.GetRegistry().GetAgent("main") if !ok || mainAgent == nil { t.Fatal("expected main agent") } @@ -241,6 +275,76 @@ Investigate deeply. } } +func TestContextBuilder_BuildMessagesOmitsAgentDiscoveryWithoutSpawnTool(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +description: Main agent +tools: [read_file] +--- +# Agent + +Generalist. +`, + }) + defer cleanupWorkspace(t, mainWorkspace) + + researchWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +description: Research specialist +--- +# Agent + +Investigate deeply. +`, + }) + defer cleanupWorkspace(t, researchWorkspace) + + cfg := testCfg([]config.AgentConfig{ + { + ID: "main", + Default: true, + Workspace: mainWorkspace, + Subagents: &config.SubagentsConfig{ + AllowAgents: []string{"research"}, + }, + }, + {ID: "research", Workspace: researchWorkspace}, + }) + cfg.Tools.ReadFile.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + mainAgent, ok := al.GetRegistry().GetAgent("main") + if !ok || mainAgent == nil { + t.Fatal("expected main agent") + } + + messages := mainAgent.ContextBuilder.BuildMessages( + nil, + "", + "handle locally", + nil, + "telegram", + "chat-1", + "", + "", + ) + if len(messages) == 0 { + t.Fatal("expected messages") + } + + systemPrompt := messages[0].Content + if strings.Contains(systemPrompt, "# Agent Discovery") { + t.Fatalf("did not expect discovery section without spawn tool, got %q", systemPrompt) + } + if strings.Contains(systemPrompt, `"id": "research"`) { + t.Fatalf("did not expect peer identity without spawn tool, got %q", systemPrompt) + } +} + func TestContextBuilder_BuildMessagesOmitsAgentDiscoverySectionForSingleton(t *testing.T) { mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- @@ -257,9 +361,13 @@ Generalist. {ID: "main", Default: true, Workspace: mainWorkspace}, }) cfg.Tools.ReadFile.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = true - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) - mainAgent, ok := registry.GetAgent("main") + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{}) + defer al.Close() + + mainAgent, ok := al.GetRegistry().GetAgent("main") if !ok || mainAgent == nil { t.Fatal("expected main agent") } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index a4d1a860d..821ad4187 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -137,6 +137,14 @@ func agentAllowsSubagent(parent *AgentInstance, targetNorm string) bool { return false } +func agentHasSpawnTool(agent *AgentInstance) bool { + if agent == nil || agent.Tools == nil { + return false + } + _, ok := agent.Tools.Get("spawn") + return ok +} + // ForEachTool calls fn for every tool registered under the given name // across all agents. This is useful for propagating dependencies (e.g. // MediaStore) to tools after registry construction. From 836220363154452217f7555c1c02c74e24b8384b Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Fri, 8 May 2026 13:50:14 -0700 Subject: [PATCH 60/71] fix(agent): transcribe queued voice follow-ups --- pkg/agent/agent.go | 2 + pkg/agent/agent_message.go | 63 +++++++++++++++--- pkg/agent/steering_test.go | 127 +++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 10 deletions(-) diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 97ee4fe7d..bc6d2b39b 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -182,6 +182,8 @@ func (al *AgentLoop) Run(ctx context.Context) error { continue } + msg = al.prepareInboundMessageForAgent(ctx, msg) + // Another turn is already active (or reserved) for this session — enqueue if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{ Role: "user", diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go index 96b0b0817..8fba50d71 100644 --- a/pkg/agent/agent_message.go +++ b/pkg/agent/agent_message.go @@ -65,6 +65,40 @@ func (al *AgentLoop) ProcessDirectWithChannel( return al.processMessage(ctx, msg) } +func (al *AgentLoop) processScheduledMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { + msg = al.prepareInboundMessageForAgent(ctx, msg) + route, agent, routeErr := al.resolveMessageRoute(msg) + if routeErr != nil { + return "", routeErr + } + allocation := al.allocateRouteSession(route, msg) + sessionKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) + + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { + resetter.ResetSentInRound(sessionKey) + } + } + + return al.runAgentLoop(ctx, agent, processOptions{ + Dispatch: DispatchRequest{ + SessionKey: sessionKey, + SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...), + InboundContext: cloneInboundContext(&msg.Context), + RouteResult: cloneResolvedRoute(&route), + SessionScope: session.CloneScope(&allocation.Scope), + UserMessage: msg.Content, + Media: append([]string(nil), msg.Media...), + }, + SenderID: msg.SenderID, + SenderDisplayName: msg.Sender.DisplayName, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + SuppressToolFeedback: true, + NoHistory: true, + }) +} func (al *AgentLoop) ProcessHeartbeat( ctx context.Context, content, channel, chatID string, @@ -102,9 +136,27 @@ func (al *AgentLoop) ProcessHeartbeat( }) } -func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { +func (al *AgentLoop) prepareInboundMessageForAgent( + ctx context.Context, + msg bus.InboundMessage, +) bus.InboundMessage { msg = bus.NormalizeInboundMessage(msg) + var hadAudio bool + msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) + + // For audio messages the placeholder was deferred by the channel. + // Now that transcription (and optional feedback) is done, send it. + if hadAudio && al.channelManager != nil { + al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) + } + + return msg +} + +func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { + msg = al.prepareInboundMessageForAgent(ctx, msg) + // Add message preview to log (show full content for error messages) var logContent string if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { @@ -123,15 +175,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }, ) - var hadAudio bool - msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) - - // For audio messages the placeholder was deferred by the channel. - // Now that transcription (and optional feedback) is done, send it. - if hadAudio && al.channelManager != nil { - al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) - } - // Route system messages to processSystemMessage if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 813013649..23d34840e 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" runtimeevents "github.com/sipeed/picoclaw/pkg/events" @@ -477,6 +478,16 @@ func (p *lateSteeringProvider) GetDefaultModel() string { return "late-steering-mock" } +type fixedTranscriber struct { + text string +} + +func (f *fixedTranscriber) Name() string { return "fixed" } + +func (f *fixedTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*asr.TranscriptionResponse, error) { + return &asr.TranscriptionResponse{Text: f.text}, nil +} + type blockingDirectProvider struct { mu sync.Mutex calls int @@ -840,6 +851,122 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) { } } +func TestAgentLoop_Run_QueuedVoiceMessageIsTranscribedBeforeSteering(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 := &lateSteeringProvider{ + firstCallStarted: make(chan struct{}), + releaseFirstCall: make(chan struct{}), + } + al := NewAgentLoop(cfg, msgBus, provider) + + store := media.NewFileMediaStore() + audioPath := filepath.Join(tmpDir, "voice.ogg") + if err := os.WriteFile(audioPath, []byte("fake audio"), 0o644); err != nil { + t.Fatalf("write audio fixture: %v", err) + } + ref, err := store.Store(audioPath, media.MediaMeta{ + Filename: "voice.ogg", + ContentType: "audio/ogg", + CleanupPolicy: media.CleanupPolicyForgetOnly, + }, "scope-voice") + if err != nil { + t.Fatalf("store audio fixture: %v", err) + } + al.SetMediaStore(store) + al.SetTranscriber(&fixedTranscriber{text: "and also two pieces of bread"}) + + runCtx, cancelRun := context.WithCancel(context.Background()) + defer cancelRun() + + runErrCh := make(chan error, 1) + go func() { + runErrCh <- al.Run(runCtx) + }() + + first := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "first meal", + } + late := bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "[voice]", + Media: []string{ref}, + } + + pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer pubCancel() + if err := msgBus.PublishInbound(pubCtx, first); err != nil { + t.Fatalf("publish first inbound: %v", err) + } + + select { + case <-provider.firstCallStarted: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for first provider call to start") + } + + if err := msgBus.PublishInbound(pubCtx, late); err != nil { + t.Fatalf("publish late voice inbound: %v", err) + } + + close(provider.releaseFirstCall) + + subCtx, subCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer subCancel() + select { + case <-msgBus.OutboundChan(): + case <-subCtx.Done(): + t.Fatal("expected outbound response") + } + + cancelRun() + select { + case err := <-runErrCh: + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for Run to stop") + } + + provider.mu.Lock() + secondMessages := append([]providers.Message(nil), provider.secondCallMessages...) + provider.mu.Unlock() + + foundTranscribedVoice := false + for _, msg := range secondMessages { + if msg.Role == "user" && strings.Contains(msg.Content, "[voice: and also two pieces of bread]") { + foundTranscribedVoice = true + break + } + } + if !foundTranscribedVoice { + t.Fatalf("expected queued voice message to be transcribed before steering injection, got %#v", secondMessages) + } +} + func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { From e1ed47b0ffdbfad9452561005e874ff4638d6962 Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Sat, 9 May 2026 00:53:23 -0700 Subject: [PATCH 61/71] fix(agent): remove unused scheduled helper --- pkg/agent/agent_message.go | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/pkg/agent/agent_message.go b/pkg/agent/agent_message.go index 8fba50d71..4d2886a80 100644 --- a/pkg/agent/agent_message.go +++ b/pkg/agent/agent_message.go @@ -65,40 +65,6 @@ func (al *AgentLoop) ProcessDirectWithChannel( return al.processMessage(ctx, msg) } -func (al *AgentLoop) processScheduledMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { - msg = al.prepareInboundMessageForAgent(ctx, msg) - route, agent, routeErr := al.resolveMessageRoute(msg) - if routeErr != nil { - return "", routeErr - } - allocation := al.allocateRouteSession(route, msg) - sessionKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey) - - if tool, ok := agent.Tools.Get("message"); ok { - if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok { - resetter.ResetSentInRound(sessionKey) - } - } - - return al.runAgentLoop(ctx, agent, processOptions{ - Dispatch: DispatchRequest{ - SessionKey: sessionKey, - SessionAliases: buildSessionAliases(sessionKey, append(allocation.SessionAliases, msg.SessionKey)...), - InboundContext: cloneInboundContext(&msg.Context), - RouteResult: cloneResolvedRoute(&route), - SessionScope: session.CloneScope(&allocation.Scope), - UserMessage: msg.Content, - Media: append([]string(nil), msg.Media...), - }, - SenderID: msg.SenderID, - SenderDisplayName: msg.Sender.DisplayName, - DefaultResponse: defaultResponse, - EnableSummary: false, - SendResponse: false, - SuppressToolFeedback: true, - NoHistory: true, - }) -} func (al *AgentLoop) ProcessHeartbeat( ctx context.Context, content, channel, chatID string, From 2ae25b10389b1608a01c2c5cda964af20c6d6837 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sat, 9 May 2026 10:35:13 +0200 Subject: [PATCH 62/71] fix(agent): treat empty AGENT.md tools as allow none --- pkg/agent/instance_test.go | 57 +++++++++++++++++++++++++++++ pkg/agent/tool_allowlist.go | 6 +++- pkg/agent/tool_allowlist_test.go | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 76e1b7f2d..dff2c0f2f 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -842,3 +842,60 @@ mcpServers: [github] t.Fatal("expected malformed frontmatter to fail closed for MCP servers") } } + +func TestNewAgentInstance_ExplicitEmptyToolsFieldBlocksAllTools(t *testing.T) { + tests := []struct { + name string + toolsSnippet string + }{ + { + name: "empty list", + toolsSnippet: "tools: []", + }, + { + name: "blank field", + toolsSnippet: "tools:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +` + tt.toolsSnippet + ` +--- +# Agent +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "default-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{Enabled: true}, + ListDir: config.ToolConfig{Enabled: true}, + }, + } + + agent := NewAgentInstance(&config.AgentConfig{ + ID: "research", + Workspace: workspace, + }, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + if got := agent.Tools.List(); len(got) != 0 { + t.Fatalf("agent tools = %v, want no registered tools", got) + } + if _, ok := agent.Tools.Get("read_file"); ok { + t.Fatal("expected read_file to be blocked by explicit empty tools field") + } + if _, ok := agent.Tools.Get("list_dir"); ok { + t.Fatal("expected list_dir to be blocked by explicit empty tools field") + } + }) + } +} diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index ad7394c7d..962f7ec05 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -144,7 +144,7 @@ func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { if frontmatterParseFailed(definition) { return []string{} } - if definition.Agent == nil || definition.Agent.Frontmatter.Tools == nil { + if definition.Agent == nil || !frontmatterDeclaresField(definition, "tools") { return nil } @@ -157,6 +157,10 @@ func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { allowlist[trimmed] = struct{}{} } + if len(allowlist) == 0 { + return []string{} + } + return sortedKeys(allowlist) } diff --git a/pkg/agent/tool_allowlist_test.go b/pkg/agent/tool_allowlist_test.go index 4851dcaa8..5ed35d4c6 100644 --- a/pkg/agent/tool_allowlist_test.go +++ b/pkg/agent/tool_allowlist_test.go @@ -68,6 +68,68 @@ tools: [serial, reaction, send_tts, load_image, delegate, made_up] } } +func TestResolveAgentToolAllowlistDistinguishesMissingAndEmptyToolsField(t *testing.T) { + tests := []struct { + name string + agentMD string + wantNil bool + wantEmpty bool + }{ + { + name: "missing tools field allows all tools", + agentMD: `--- +name: pico +--- +# Agent +`, + wantNil: true, + }, + { + name: "explicit empty tools list blocks all tools", + agentMD: `--- +tools: [] +--- +# Agent +`, + wantEmpty: true, + }, + { + name: "blank tools field blocks all tools", + agentMD: `--- +tools: +--- +# Agent +`, + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": tt.agentMD, + }) + defer cleanupWorkspace(t, workspace) + + allowlist := resolveAgentToolAllowlist(loadAgentDefinition(workspace)) + + if tt.wantNil { + if allowlist != nil { + t.Fatalf("resolveAgentToolAllowlist() = %v, want nil", allowlist) + } + return + } + + if allowlist == nil { + t.Fatal("resolveAgentToolAllowlist() = nil, want explicit empty allowlist") + } + if len(allowlist) != 0 { + t.Fatalf("resolveAgentToolAllowlist() = %v, want empty allowlist", allowlist) + } + }) + } +} + func TestUnknownAgentMCPServerNames(t *testing.T) { workspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- From 09d3dff4322c24a361ac9d1307d5344436358fe6 Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Sun, 3 May 2026 20:40:20 -0700 Subject: [PATCH 63/71] fix telegram media group album handling --- docs/channels/telegram/README.md | 4 +- pkg/channels/telegram/telegram.go | 295 ++++++++++++++++++++----- pkg/channels/telegram/telegram_test.go | 188 ++++++++++++++++ pkg/config/config.go | 11 +- pkg/config/defaults.go | 5 +- 5 files changed, 437 insertions(+), 66 deletions(-) diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index a4138009e..215d80afe 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -15,7 +15,8 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], "proxy": "", - "use_markdown_v2": false + "use_markdown_v2": false, + "media_group_delay_ms": 500 } } } @@ -28,6 +29,7 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co | allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | | proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | | use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting | +| media_group_delay_ms | int | No | Idle delay before processing Telegram media groups/albums. Defaults to 500 ms | ## Setup diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index cebebfed6..b3685a7c6 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "regexp" + "slices" "strconv" "strings" "sync" @@ -43,20 +44,38 @@ var ( reInlineCode = regexp.MustCompile("`([^`]+)`") ) +const defaultMediaGroupDelay = 500 * time.Millisecond + type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *th.BotHandler - bc *config.Channel - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc - tgCfg *config.TelegramSettings - progress *channels.ToolFeedbackAnimator + bot *telego.Bot + bh *th.BotHandler + bc *config.Channel + chatIDsMu sync.Mutex + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc + tgCfg *config.TelegramSettings + progress *channels.ToolFeedbackAnimator registerFunc func(context.Context, []commands.Definition) error commandRegDelayFn func(int) time.Duration commandRegCancel context.CancelFunc + + mediaGroupMu sync.Mutex + mediaGroups map[string]*telegramMediaGroup + mediaGroupDelay time.Duration +} + +type telegramMediaGroup struct { + messages []*telego.Message + timer *time.Timer + generation uint64 +} + +type telegramMessageParts struct { + content []string + mediaPaths []string } func NewTelegramChannel( @@ -112,11 +131,21 @@ func NewTelegramChannel( bc: bc, chatIDs: make(map[string]int64), tgCfg: telegramCfg, + + mediaGroups: make(map[string]*telegramMediaGroup), + mediaGroupDelay: telegramMediaGroupDelay(telegramCfg), } ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) return ch, nil } +func telegramMediaGroupDelay(telegramCfg *config.TelegramSettings) time.Duration { + if telegramCfg != nil && telegramCfg.MediaGroupDelayMS > 0 { + return time.Duration(telegramCfg.MediaGroupDelayMS) * time.Millisecond + } + return defaultMediaGroupDelay +} + func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") @@ -167,6 +196,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { if c.bh != nil { _ = c.bh.StopWithContext(ctx) } + c.flushPendingMediaGroups(ctx) // Cancel our context (stops long polling) if c.cancel != nil { @@ -713,6 +743,131 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { + if message != nil && strings.TrimSpace(message.MediaGroupID) != "" { + return c.bufferMediaGroupMessage(ctx, message) + } + return c.handleMessages(ctx, []*telego.Message{message}) +} + +func (c *TelegramChannel) bufferMediaGroupMessage(ctx context.Context, message *telego.Message) error { + if message == nil { + return fmt.Errorf("message is nil") + } + groupID := strings.TrimSpace(message.MediaGroupID) + if groupID == "" { + return c.handleMessages(ctx, []*telego.Message{message}) + } + + msgCopy := *message + msgCopy.Photo = append([]telego.PhotoSize(nil), message.Photo...) + key := fmt.Sprintf("%d:%s", message.Chat.ID, groupID) + + c.mediaGroupMu.Lock() + if c.mediaGroups == nil { + c.mediaGroups = make(map[string]*telegramMediaGroup) + } + group := c.mediaGroups[key] + if group == nil { + group = &telegramMediaGroup{} + c.mediaGroups[key] = group + } + group.messages = append(group.messages, &msgCopy) + group.generation++ + generation := group.generation + if group.timer != nil { + group.timer.Stop() + } + delay := c.mediaGroupDelay + if delay <= 0 { + delay = defaultMediaGroupDelay + } + group.timer = time.AfterFunc(delay, func() { + c.flushMediaGroup(c.ctx, key, generation) + }) + c.mediaGroupMu.Unlock() + + logger.DebugCF("telegram", "Buffered media group message", map[string]any{ + "chat_id": message.Chat.ID, + "media_group_id": groupID, + "message_id": message.MessageID, + }) + return nil +} + +func (c *TelegramChannel) flushPendingMediaGroups(ctx context.Context) { + c.mediaGroupMu.Lock() + keys := make([]string, 0, len(c.mediaGroups)) + for key, group := range c.mediaGroups { + if group.timer != nil { + group.timer.Stop() + } + keys = append(keys, key) + } + c.mediaGroupMu.Unlock() + + for _, key := range keys { + c.flushMediaGroup(ctx, key, 0) + } +} + +func (c *TelegramChannel) flushMediaGroup(ctx context.Context, key string, generation uint64) { + c.mediaGroupMu.Lock() + group := c.mediaGroups[key] + if group == nil { + c.mediaGroupMu.Unlock() + return + } + if generation != 0 && group.generation != generation { + c.mediaGroupMu.Unlock() + return + } + delete(c.mediaGroups, key) + if group.timer != nil { + group.timer.Stop() + } + messages := append([]*telego.Message(nil), group.messages...) + c.mediaGroupMu.Unlock() + + if len(messages) == 0 { + return + } + slices.SortFunc(messages, func(a, b *telego.Message) int { + switch { + case a == nil && b == nil: + return 0 + case a == nil: + return -1 + case b == nil: + return 1 + default: + return a.MessageID - b.MessageID + } + }) + if ctx == nil { + ctx = context.Background() + } + if err := c.handleMessages(ctx, messages); err != nil { + logger.ErrorCF("telegram", "Failed to handle media group", map[string]any{ + "key": key, + "error": err.Error(), + }) + } +} + +func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego.Message) error { + if len(messages) == 0 { + return nil + } + message := messages[0] + for _, candidate := range messages { + if candidate == nil { + continue + } + if strings.TrimSpace(candidate.Text) != "" || strings.TrimSpace(candidate.Caption) != "" { + message = candidate + break + } + } if message == nil { return fmt.Errorf("message is nil") } @@ -740,7 +895,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } chatID := message.Chat.ID + c.chatIDsMu.Lock() c.chatIDs[platformID] = chatID + c.chatIDsMu.Unlock() content := "" mediaPaths := []string{} @@ -764,61 +921,18 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return localPath // fallback: use raw path } - if message.Text != "" { - content += message.Text - } - - if message.Caption != "" { - if content != "" { - content += "\n" + for i, msg := range messages { + if msg == nil { + continue } - content += message.Caption - } - - if len(message.Photo) > 0 { - photo := message.Photo[len(message.Photo)-1] - photoPath := c.downloadPhoto(ctx, photo.FileID) - if photoPath != "" { - mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg")) + parts := c.collectTelegramMessageParts(ctx, msg, i, len(messages), storeMedia) + for _, part := range parts.content { if content != "" { content += "\n" } - content += "[image: photo]" - } - } - - if message.Voice != nil { - voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") - if voicePath != "" { - mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) - - if content != "" { - content += "\n" - } - content += "[voice]" - } - } - - if message.Audio != nil { - audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") - if audioPath != "" { - mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3")) - if content != "" { - content += "\n" - } - content += "[audio]" - } - } - - if message.Document != nil { - docPath := c.downloadFile(ctx, message.Document.FileID, "") - if docPath != "" { - mediaPaths = append(mediaPaths, storeMedia(docPath, "document")) - if content != "" { - content += "\n" - } - content += "[file]" + content += part } + mediaPaths = append(mediaPaths, parts.mediaPaths...) } if content == "" && len(mediaPaths) == 0 { @@ -917,6 +1031,71 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return nil } +func (c *TelegramChannel) collectTelegramMessageParts( + ctx context.Context, + msg *telego.Message, + index int, + total int, + storeMedia func(localPath, filename string) string, +) telegramMessageParts { + parts := telegramMessageParts{} + if msg == nil { + return parts + } + if text := strings.TrimSpace(msg.Text); text != "" { + parts.content = append(parts.content, text) + } + if caption := strings.TrimSpace(msg.Caption); caption != "" { + parts.content = append(parts.content, caption) + } + if len(msg.Photo) > 0 { + photo := msg.Photo[len(msg.Photo)-1] + photoPath := c.downloadPhoto(ctx, photo.FileID) + if photoPath != "" { + photoNumber := index + 1 + parts.mediaPaths = append(parts.mediaPaths, storeMedia(photoPath, fmt.Sprintf("photo-%d.jpg", photoNumber))) + parts.content = append(parts.content, fmt.Sprintf("[image: photo %d]", photoNumber)) + } + } + if msg.Voice != nil { + voicePath := c.downloadFile(ctx, msg.Voice.FileID, ".ogg") + if voicePath != "" { + parts.mediaPaths = append(parts.mediaPaths, storeMedia(voicePath, indexedMediaFilename("voice", ".ogg", index, total))) + parts.content = append(parts.content, "[voice]") + } + } + if msg.Audio != nil { + audioPath := c.downloadFile(ctx, msg.Audio.FileID, ".mp3") + if audioPath != "" { + filename := msg.Audio.FileName + if strings.TrimSpace(filename) == "" { + filename = indexedMediaFilename("audio", ".mp3", index, total) + } + parts.mediaPaths = append(parts.mediaPaths, storeMedia(audioPath, filename)) + parts.content = append(parts.content, "[audio]") + } + } + if msg.Document != nil { + docPath := c.downloadFile(ctx, msg.Document.FileID, "") + if docPath != "" { + filename := msg.Document.FileName + if strings.TrimSpace(filename) == "" { + filename = indexedMediaFilename("document", "", index, total) + } + parts.mediaPaths = append(parts.mediaPaths, storeMedia(docPath, filename)) + parts.content = append(parts.content, "[file]") + } + } + return parts +} + +func indexedMediaFilename(prefix, ext string, index int, total int) string { + if total <= 1 { + return prefix + ext + } + return fmt.Sprintf("%s-%d%s", prefix, index+1, ext) +} + func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string { quoted := strings.TrimSpace(telegramQuotedContent(reply)) if quoted == "" { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 69c76b430..14d025064 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/mymmrac/telego" ta "github.com/mymmrac/telego/telegoapi" @@ -1100,3 +1101,190 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) { default: } } + +func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) { + messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond) + base := testMediaGroupMessage("album-1") + first := base + first.MessageID = 1 + second := base + second.MessageID = 2 + second.Caption = "meal caption" + + require.NoError(t, ch.handleMessage(context.Background(), &first)) + require.NoError(t, ch.handleMessage(context.Background(), &second)) + + select { + case inbound := <-messageBus.InboundChan(): + assert.Equal(t, "2", inbound.Context.MessageID) + assert.Equal(t, "meal caption", inbound.Content) + case <-time.After(time.Second): + t.Fatal("timed out waiting for combined media group message") + } +} + +func TestHandleMessage_MediaGroupWaitsForStaggeredMessages(t *testing.T) { + messageBus, ch := newMediaGroupTestChannel(100 * time.Millisecond) + base := testMediaGroupMessage("album-staggered") + first := base + first.MessageID = 1 + first.Caption = "first caption" + second := base + second.MessageID = 2 + second.Caption = "second caption" + + require.NoError(t, ch.handleMessage(context.Background(), &first)) + time.Sleep(50 * time.Millisecond) + require.NoError(t, ch.handleMessage(context.Background(), &second)) + + select { + case inbound := <-messageBus.InboundChan(): + t.Fatalf("media group flushed before idle delay reset: %#v", inbound) + case <-time.After(75 * time.Millisecond): + } + + select { + case inbound := <-messageBus.InboundChan(): + assert.Equal(t, "1", inbound.Context.MessageID) + assert.Equal(t, "first caption\nsecond caption", inbound.Content) + case <-time.After(time.Second): + t.Fatal("timed out waiting for staggered media group message") + } +} + +func TestFlushMediaGroupIgnoresStaleTimerGeneration(t *testing.T) { + messageBus, ch := newMediaGroupTestChannel(time.Hour) + base := testMediaGroupMessage("album-generation") + first := base + first.MessageID = 1 + first.Caption = "first" + second := base + second.MessageID = 2 + second.Caption = "second" + key := "456:album-generation" + + ch.mediaGroupMu.Lock() + ch.mediaGroups[key] = &telegramMediaGroup{ + messages: []*telego.Message{&first, &second}, + generation: 2, + } + ch.mediaGroupMu.Unlock() + + ch.flushMediaGroup(context.Background(), key, 1) + + select { + case inbound := <-messageBus.InboundChan(): + t.Fatalf("stale media group generation flushed unexpectedly: %#v", inbound) + default: + } + + ch.mediaGroupMu.Lock() + _, stillPending := ch.mediaGroups[key] + ch.mediaGroupMu.Unlock() + require.True(t, stillPending, "stale flush should leave the current batch pending") + + ch.flushMediaGroup(context.Background(), key, 2) + + select { + case inbound := <-messageBus.InboundChan(): + assert.Equal(t, "1", inbound.Context.MessageID) + assert.Equal(t, "first\nsecond", inbound.Content) + case <-time.After(time.Second): + t.Fatal("timed out waiting for current generation media group flush") + } +} + +func TestHandleMessage_MediaGroupAfterDelayStartsNewBatch(t *testing.T) { + messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond) + base := testMediaGroupMessage("album-split") + first := base + first.MessageID = 1 + first.Caption = "first" + second := base + second.MessageID = 2 + second.Caption = "second" + + require.NoError(t, ch.handleMessage(context.Background(), &first)) + select { + case inbound := <-messageBus.InboundChan(): + assert.Equal(t, "1", inbound.Context.MessageID) + assert.Equal(t, "first", inbound.Content) + case <-time.After(time.Second): + t.Fatal("timed out waiting for first media group batch") + } + + require.NoError(t, ch.handleMessage(context.Background(), &second)) + select { + case inbound := <-messageBus.InboundChan(): + assert.Equal(t, "2", inbound.Context.MessageID) + assert.Equal(t, "second", inbound.Content) + case <-time.After(time.Second): + t.Fatal("timed out waiting for second media group batch") + } +} + +func TestStopFlushesPendingMediaGroups(t *testing.T) { + messageBus, ch := newMediaGroupTestChannel(time.Hour) + base := testMediaGroupMessage("album-stop") + msg := base + msg.MessageID = 1 + msg.Caption = "caption before stop" + + require.NoError(t, ch.handleMessage(context.Background(), &msg)) + require.NoError(t, ch.Stop(context.Background())) + + select { + case inbound := <-messageBus.InboundChan(): + assert.Equal(t, "1", inbound.Context.MessageID) + assert.Equal(t, "caption before stop", inbound.Content) + case <-time.After(time.Second): + t.Fatal("timed out waiting for pending media group flush on stop") + } +} + +func TestNewTelegramChannelUsesConfiguredMediaGroupDelay(t *testing.T) { + ch, err := NewTelegramChannel( + &config.Channel{Type: config.ChannelTelegram, Enabled: true}, + &config.TelegramSettings{ + Token: *config.NewSecureString(testToken), + MediaGroupDelayMS: 750, + }, + bus.NewMessageBus(), + ) + require.NoError(t, err) + assert.Equal(t, 750*time.Millisecond, ch.mediaGroupDelay) + + ch, err = NewTelegramChannel( + &config.Channel{Type: config.ChannelTelegram, Enabled: true}, + &config.TelegramSettings{Token: *config.NewSecureString(testToken)}, + bus.NewMessageBus(), + ) + require.NoError(t, err) + assert.Equal(t, defaultMediaGroupDelay, ch.mediaGroupDelay) +} + +func newMediaGroupTestChannel(delay time.Duration) (*bus.MessageBus, *TelegramChannel) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + mediaGroups: make(map[string]*telegramMediaGroup), + mediaGroupDelay: delay, + } + return messageBus, ch +} + +func testMediaGroupMessage(mediaGroupID string) telego.Message { + return telego.Message{ + Chat: telego.Chat{ + ID: 456, + Type: "private", + }, + From: &telego.User{ + ID: 789, + FirstName: "User", + }, + MediaGroupID: mediaGroupID, + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index c9d90e0f8..7956cc090 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -359,11 +359,12 @@ type WhatsAppSettings struct { } type TelegramSettings struct { - Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` - Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` - UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` + UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` + MediaGroupDelayMS int `json:"media_group_delay_ms" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_MEDIA_GROUP_DELAY_MS"` } type FeishuSettings struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 8e2494ae5..26f00fa84 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -496,8 +496,9 @@ func defaultChannels() ChannelsConfig { "typing": map[string]any{"enabled": true}, "placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}}, "settings": map[string]any{ - "streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200}, - "use_markdown_v2": false, + "streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200}, + "use_markdown_v2": false, + "media_group_delay_ms": 500, }, }, "feishu": map[string]any{}, From 6801cc7ab8971019c0385e30470077f4307f4b1c Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Thu, 7 May 2026 14:52:10 -0700 Subject: [PATCH 64/71] fix(telegram): wrap long voice media append --- pkg/channels/telegram/telegram.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index b3685a7c6..0965bcedc 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -1060,7 +1060,10 @@ func (c *TelegramChannel) collectTelegramMessageParts( if msg.Voice != nil { voicePath := c.downloadFile(ctx, msg.Voice.FileID, ".ogg") if voicePath != "" { - parts.mediaPaths = append(parts.mediaPaths, storeMedia(voicePath, indexedMediaFilename("voice", ".ogg", index, total))) + parts.mediaPaths = append( + parts.mediaPaths, + storeMedia(voicePath, indexedMediaFilename("voice", ".ogg", index, total)), + ) parts.content = append(parts.content, "[voice]") } } From 91f024eb1d6cb45397d2174f2e0f6b96c4b73163 Mon Sep 17 00:00:00 2001 From: xp Date: Wed, 6 May 2026 20:50:58 +0800 Subject: [PATCH 65/71] fix(gateway): keep media store aligned after reload --- pkg/channels/manager.go | 22 +++++++++++++++++++++- pkg/channels/manager_test.go | 21 +++++++++++++++++++++ pkg/gateway/gateway.go | 3 +++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index f7f625271..8a8f9dc03 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -100,6 +100,10 @@ type Manager struct { channelHashes map[string]string // channel name → config hash } +type mediaStoreSetter interface { + SetMediaStore(s media.MediaStore) +} + // ManagerOption configures a channel Manager. type ManagerOption func(*Manager) @@ -485,6 +489,22 @@ func NewManager( return m, nil } +// SetMediaStore updates the store used by the manager and every channel that +// accepts media store injection. Gateway reload creates a fresh store, so +// keeping existing channels on the same store as the agent is required for +// inbound media refs to remain resolvable after reload. +func (m *Manager) SetMediaStore(store media.MediaStore) { + m.mu.Lock() + defer m.mu.Unlock() + + m.mediaStore = store + for _, ch := range m.channels { + if setter, ok := ch.(mediaStoreSetter); ok { + setter.SetMediaStore(store) + } + } +} + // GetStreamer implements bus.StreamDelegate. // It checks if the named channel supports streaming and returns a Streamer. func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (bus.Streamer, bool) { @@ -582,7 +602,7 @@ func (m *Manager) initChannel(typeName, channelName string) { } else { // Inject MediaStore if channel supports it if m.mediaStore != nil { - if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok { + if setter, ok := ch.(mediaStoreSetter); ok { setter.SetMediaStore(m.mediaStore) } } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 5aeabc888..8c2f6ecf8 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" runtimeevents "github.com/sipeed/picoclaw/pkg/events" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -149,6 +150,26 @@ func newTestManager() *Manager { } } +func TestSetMediaStorePropagatesToExistingChannels(t *testing.T) { + oldStore := media.NewFileMediaStore() + newStore := media.NewFileMediaStore() + ch := &mockChannel{} + ch.SetMediaStore(oldStore) + + m := newTestManager() + m.mediaStore = oldStore + m.channels["telegram"] = ch + + m.SetMediaStore(newStore) + + if m.mediaStore != newStore { + t.Fatal("manager media store was not updated") + } + if got := ch.GetMediaStore(); got != newStore { + t.Fatalf("channel media store = %p, want %p", got, newStore) + } +} + func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) { m := newTestManager() errA := errors.New("channel-a start failed") diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index bf64e0453..6171fd65f 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -646,6 +646,9 @@ func restartServices( if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { fms.Start() } + if runningServices.ChannelManager != nil { + runningServices.ChannelManager.SetMediaStore(runningServices.MediaStore) + } al.SetMediaStore(runningServices.MediaStore) al.SetChannelManager(runningServices.ChannelManager) From 1055e082a427f8e055465cb64456e3271e038fba Mon Sep 17 00:00:00 2001 From: "Gabriel S. Vieira" Date: Mon, 11 May 2026 00:09:27 -0300 Subject: [PATCH 66/71] Add MCP section to config web UI (#2770) * Add MCP section to config UI * Handle MCP sse and URL-based server mapping * Validate duplicate MCP server names before save * Disable MCP discovery options based on mutual exclusivity in config section Co-authored-by: Copilot * Clear stale MCP transport fields in patch payload * Fix MCP config form state preservation and validation * Avoid MCP form ID collisions for distinct server names * Validate remote MCP URLs in config UI * fix(config): correct MCP discovery merge patch behavior * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(config): align MCP discovery semantics and MCP server editor behavior * fix(config): validate MCP server fields only when active --------- Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/components/config/config-page.tsx | 255 +++++++++++++ .../src/components/config/config-sections.tsx | 344 +++++++++++++++++- .../src/components/config/form-model.ts | 138 +++++++ web/frontend/src/i18n/locales/en.json | 31 ++ web/frontend/src/i18n/locales/zh.json | 31 ++ 5 files changed, 796 insertions(+), 3 deletions(-) diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 0b5665640..f74b258f6 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -22,6 +22,7 @@ import { DevicesSection, ExecSection, LauncherSection, + MCPSection, RuntimeSection, } from "@/components/config/config-sections" import { @@ -29,9 +30,11 @@ import { EMPTY_FORM, EMPTY_LAUNCHER_FORM, type LauncherForm, + type MCPServerForm, buildFormFromConfig, parseCIDRText, parseIntField, + parseJSONObjectField, parseMultilineList, } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" @@ -40,6 +43,21 @@ import { Button } from "@/components/ui/button" import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" +function buildStringMapMergePatch( + next: Record, + previous: Record, +): Record { + const patch: Record = { ...next } + + for (const key of Object.keys(previous)) { + if (!(key in next)) { + patch[key] = null + } + } + + return patch +} + export function ConfigPage() { const { t } = useTranslation() const queryClient = useQueryClient() @@ -143,6 +161,44 @@ export function ConfigPage() { setLauncherForm((prev) => ({ ...prev, [key]: value })) } + const handleMCPServerAdd = () => { + const nextIndex = form.mcpServers.length + 1 + const server: MCPServerForm = { + id: `mcp-${Date.now()}-${nextIndex}`, + name: "", + enabled: true, + deferredOverride: null, + type: "stdio", + url: "", + command: "", + argsText: "", + envText: "{}", + envFile: "", + headersText: "{}", + } + updateField("mcpServers", [...form.mcpServers, server]) + } + + const handleMCPServerRemove = (id: string) => { + updateField( + "mcpServers", + form.mcpServers.filter((server) => server.id !== id), + ) + } + + const handleMCPServerFieldChange = ( + id: string, + key: K, + value: MCPServerForm[K], + ) => { + updateField( + "mcpServers", + form.mcpServers.map((server) => + server.id === id ? { ...server, [key]: value } : server, + ), + ) + } + const handleReset = () => { setForm(baseline) setLauncherForm(launcherBaseline) @@ -178,6 +234,17 @@ export function ConfigPage() { throw new Error("Session scope is required.") } + if ( + form.mcpEnabled && + form.mcpDiscoveryEnabled && + !form.mcpDiscoveryUseBM25 && + !form.mcpDiscoveryUseRegex + ) { + throw new Error( + "MCP discovery requires at least one search method (BM25 or regex).", + ) + } + const maxTokens = parseIntField(form.maxTokens, "Max tokens", { min: 1, }) @@ -214,10 +281,185 @@ export function ConfigPage() { "Cron exec timeout", { min: 0 }, ) + const mcpDiscoveryValidationEnabled = + form.mcpEnabled && form.mcpDiscoveryEnabled + const mcpDiscoveryPatch: Record = { + enabled: form.mcpDiscoveryEnabled, + use_bm25: form.mcpDiscoveryUseBM25, + use_regex: form.mcpDiscoveryUseRegex, + } + + if (mcpDiscoveryValidationEnabled) { + mcpDiscoveryPatch.ttl = parseIntField( + form.mcpDiscoveryTTL, + "MCP discovery ttl", + { + min: 1, + }, + ) + mcpDiscoveryPatch.max_search_results = parseIntField( + form.mcpDiscoveryMaxSearchResults, + "MCP discovery max search results", + { min: 1 }, + ) + } const execConfigPatch: Record = { enabled: form.execEnabled, } + let mcpServersPatch: Record | null> = {} + if (form.mcpEnabled) { + const baselineServerNames = new Set( + baseline.mcpServers + .map((server) => server.name.trim()) + .filter((name) => name !== ""), + ) + + const normalizedServers = form.mcpServers + .map((server) => ({ + ...server, + name: server.name.trim(), + url: server.url.trim(), + command: server.command.trim(), + envFile: server.envFile.trim(), + })) + .filter((server) => server.name !== "") + + const serverNameCounts = new Map() + for (const server of normalizedServers) { + serverNameCounts.set( + server.name, + (serverNameCounts.get(server.name) ?? 0) + 1, + ) + } + + const duplicateNames = Array.from(serverNameCounts.entries()) + .filter(([, count]) => count > 1) + .map(([name]) => name) + .sort((a, b) => a.localeCompare(b)) + + if (duplicateNames.length > 0) { + throw new Error( + `MCP server names must be unique. Duplicates: ${duplicateNames.join(", ")}.`, + ) + } + + const currentServerNames = new Set( + normalizedServers.map((server) => server.name), + ) + + const removedServerEntries = Array.from(baselineServerNames) + .filter((name) => !currentServerNames.has(name)) + .map((name) => [name, null] as const) + + const baselineServersByName = new Map( + baseline.mcpServers + .map((server) => ({ + ...server, + name: server.name.trim(), + })) + .filter((server) => server.name !== "") + .map((server) => [server.name, server] as const), + ) + + const upsertServerEntries = normalizedServers.map((server) => { + const deferredPatch = { deferred: server.deferredOverride } + const baselineServer = baselineServersByName.get(server.name) + const shouldValidateServer = server.enabled + + if (server.type !== "stdio") { + if (shouldValidateServer && server.url === "") { + throw new Error(`MCP server ${server.name} requires a URL.`) + } + + if (shouldValidateServer) { + try { + const parsedURL = new URL(server.url) + if ( + parsedURL.protocol !== "http:" && + parsedURL.protocol !== "https:" + ) { + throw new Error("invalid protocol") + } + } catch { + throw new Error( + `MCP server ${server.name} requires a valid HTTP(S) URL.`, + ) + } + } + + const baselineHeaders = baselineServer + ? parseJSONObjectField( + baselineServer.headersText, + `Saved MCP server ${server.name} headers`, + ) + : {} + + return [ + server.name, + { + ...deferredPatch, + enabled: server.enabled, + type: server.type, + url: server.url, + headers: buildStringMapMergePatch( + shouldValidateServer + ? parseJSONObjectField( + server.headersText, + `MCP server ${server.name} headers`, + ) + : baselineHeaders, + baselineHeaders, + ), + command: null, + args: null, + env: null, + env_file: null, + }, + ] as const + } + + if (shouldValidateServer && server.command === "") { + throw new Error(`MCP server ${server.name} requires a command.`) + } + + const baselineEnv = baselineServer + ? parseJSONObjectField( + baselineServer.envText, + `Saved MCP server ${server.name} env`, + ) + : {} + + return [ + server.name, + { + ...deferredPatch, + enabled: server.enabled, + type: "stdio", + command: server.command, + args: parseMultilineList(server.argsText), + env: buildStringMapMergePatch( + shouldValidateServer + ? parseJSONObjectField( + server.envText, + `MCP server ${server.name} env`, + ) + : baselineEnv, + baselineEnv, + ), + env_file: server.envFile === "" ? null : server.envFile, + url: null, + headers: null, + }, + ] as const + }) + + mcpServersPatch = Object.fromEntries([ + ...upsertServerEntries, + ...removedServerEntries, + ]) + } + if (form.execEnabled) { execConfigPatch.allow_remote = form.allowRemote execConfigPatch.enable_deny_patterns = form.enableDenyPatterns @@ -264,6 +506,11 @@ export function ConfigPage() { exec_timeout_minutes: cronExecTimeoutMinutes, }, exec: execConfigPatch, + mcp: { + enabled: form.mcpEnabled, + discovery: mcpDiscoveryPatch, + servers: mcpServersPatch, + }, }, heartbeat: { enabled: form.heartbeatEnabled, @@ -414,6 +661,14 @@ export function ConfigPage() { + + diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index fa6b3a079..f71f025a6 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -1,3 +1,4 @@ +import { IconPlus, IconTrash } from "@tabler/icons-react" import { useState } from "react" import type { ReactNode } from "react" import { useTranslation } from "react-i18next" @@ -6,6 +7,8 @@ import { type CoreConfigForm, DM_SCOPE_OPTIONS, type LauncherForm, + type MCPServerForm, + type MCPServerType, } from "@/components/config/form-model" import { Field, SwitchCardField } from "@/components/shared-form" import { Button } from "@/components/ui/button" @@ -221,6 +224,343 @@ interface ExecSectionProps { onFieldChange: UpdateCoreField } +interface MCPSectionProps { + form: CoreConfigForm + onFieldChange: UpdateCoreField + onAddServer: () => void + onRemoveServer: (id: string) => void + onServerFieldChange: ( + id: string, + key: K, + value: MCPServerForm[K], + ) => void +} + +export function MCPSection({ + form, + onFieldChange, + onAddServer, + onRemoveServer, + onServerFieldChange, +}: MCPSectionProps) { + const { t } = useTranslation() + + return ( + + onFieldChange("mcpEnabled", checked)} + /> + + {form.mcpEnabled && ( + <> + + onFieldChange("mcpDiscoveryEnabled", checked) + } + /> + + {form.mcpDiscoveryEnabled && ( + <> + + + onFieldChange("mcpDiscoveryTTL", e.target.value) + } + /> + + + + + onFieldChange( + "mcpDiscoveryMaxSearchResults", + e.target.value, + ) + } + /> + + + + onFieldChange("mcpDiscoveryUseBM25", checked) + } + /> + + + onFieldChange("mcpDiscoveryUseRegex", checked) + } + /> + + )} + + +
+ {form.mcpServers.map((server) => ( +
+
+
+ {server.name.trim() || t("pages.config.mcp_server_new")} +
+ +
+ +
+ + onServerFieldChange(server.id, "name", e.target.value) + } + /> + + + + + onServerFieldChange(server.id, "enabled", checked) + } + /> + + +
+ + {server.type !== "stdio" ? ( +
+ + onServerFieldChange(server.id, "url", e.target.value) + } + /> +