feat(agent): add multi-agent discovery prompt and per-agent

This commit is contained in:
afjcjsbx 2026-03-29 13:58:19 +02:00
parent e70928cc6f
commit 3b173c0bee
70 changed files with 2453 additions and 402 deletions

View file

@ -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. - **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. - **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 ### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.

View file

@ -42,14 +42,13 @@ PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/works
├── state/ # Stato persistente (ultimo canale, ecc.) ├── state/ # Stato persistente (ultimo canale, ecc.)
├── cron/ # Database dei job pianificati ├── cron/ # Database dei job pianificati
├── skills/ # Skill personalizzate ├── 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) ├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
├── IDENTITY.md # Identità dell'agent
├── SOUL.md # Anima dell'agent ├── SOUL.md # Anima dell'agent
└── USER.md # Preferenze dell'utente └── 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 ### 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 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. - 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 ### 🔒 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. PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.

View file

@ -22,11 +22,13 @@ import (
type ContextBuilder struct { type ContextBuilder struct {
workspace string workspace string
agentID string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore memory *MemoryStore
toolDiscoveryBM25 bool toolDiscoveryBM25 bool
toolDiscoveryRegex bool toolDiscoveryRegex bool
splitOnMarker bool splitOnMarker bool
agentDiscovery func(workspace string) []AgentDescriptor
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
@ -58,6 +60,18 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder {
return cb 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 { func getGlobalConfigDir() string {
if home := os.Getenv(config.EnvHome); home != "" { if home := os.Getenv(config.EnvHome); home != "" {
return 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. 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`, %s`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) version,
workspacePath,
workspacePath,
workspacePath,
workspacePath,
workspacePath,
toolDiscovery,
)
} }
func (cb *ContextBuilder) getDiscoveryRule() string { 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") 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 // BuildSystemPromptWithCache returns the cached system prompt if available
// and source files haven't changed, otherwise builds and caches it. // and source files haven't changed, otherwise builds and caches it.
// Source file changes are detected via mtime checks (cheap stat calls). // 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)") now := time.Now().Format("2006-01-02 15:04 (Monday)")
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) 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 // Build short dynamic context (time, runtime, session) — changes per request
dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName) dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName)
discoveryCtx := cb.buildAgentDiscoveryContext()
// Compose a single system message: static (cached) + dynamic + optional summary. // Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can // Keeping all system content in one message ensures every provider adapter can
@ -550,16 +581,33 @@ func (cb *ContextBuilder) BuildMessages(
// cache-aware adapters (Anthropic) can set per-block cache_control. // cache-aware adapters (Anthropic) can set per-block cache_control.
// The static block is marked "ephemeral" — its prefix hash is stable // The static block is marked "ephemeral" — its prefix hash is stable
// across requests, enabling LLM-side KV cache reuse. // across requests, enabling LLM-side KV cache reuse.
stringParts := []string{staticPrompt, dynamicCtx} stringParts := []string{staticPrompt}
contentBlocks := []providers.ContentBlock{ contentBlocks := []providers.ContentBlock{
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, {
{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 != "" { if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
stringParts = append(stringParts, 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 != "" { 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", "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
summary) summary)
stringParts = append(stringParts, summaryText) 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") fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
@ -667,7 +718,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
case "assistant": case "assistant":
if len(msg.ToolCalls) > 0 { if len(msg.ToolCalls) > 0 {
if len(sanitized) == 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 continue
} }
prev := sanitized[len(sanitized)-1] prev := sanitized[len(sanitized)-1]

View file

@ -500,8 +500,11 @@ func TestEstimateMessageTokens_ReasoningContent(t *testing.T) {
reasoningTokens := estimateMessageTokens(withReasoning) reasoningTokens := estimateMessageTokens(withReasoning)
if reasoningTokens <= plainTokens { if reasoningTokens <= plainTokens {
t.Errorf("message with ReasoningContent (%d tokens) should exceed plain message (%d tokens)", t.Errorf(
reasoningTokens, plainTokens) "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) tokensNoReasoning := estimateMessageTokens(msgNoReasoning)
if tokens <= tokensNoReasoning { 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,
)
} }
} }

View file

@ -82,7 +82,16 @@ func TestSingleSystemMessage(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 systemCount := 0
for _, m := range msgs { for _, m := range msgs {
@ -168,7 +177,16 @@ func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 sys := msgs[0].Content
if tt.wantSection { if tt.wantSection {
@ -382,7 +400,10 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
// Cache should auto-invalidate because file went from absent -> present // Cache should auto-invalidate because file went from absent -> present
sp2 := cb.BuildSystemPromptWithCache() sp2 := cb.BuildSystemPromptWithCache()
if !strings.Contains(sp2, tt.checkField) { 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,
)
} }
}) })
} }

View file

@ -151,7 +151,19 @@ func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
if len(result) != 9 { if len(result) != 9 {
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) 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) { func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
@ -170,7 +182,18 @@ func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
if len(result) != 8 { if len(result) != 8 {
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result)) 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) { func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
@ -304,5 +327,17 @@ func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
if len(result) != 9 { if len(result) != 9 {
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) 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",
)
} }

View file

@ -61,8 +61,12 @@ Act directly and use tools first.
if len(definition.Agent.Frontmatter.Skills) != 2 { if len(definition.Agent.Frontmatter.Skills) != 2 {
t.Fatalf("expected skills to be parsed, got %v", definition.Agent.Frontmatter.Skills) 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" { if len(definition.Agent.Frontmatter.MCPServers) != 1 ||
t.Fatalf("expected mcpServers to be parsed, got %v", definition.Agent.Frontmatter.MCPServers) 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 { if definition.Agent.Frontmatter.Fields["metadata"] == nil {
t.Fatal("expected arbitrary frontmatter fields to remain available") 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") t.Fatal("expected AGENTS.md to be loaded")
} }
if definition.Agent.RawFrontmatter != "" { 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") { if !strings.Contains(definition.Agent.Body, "Keep compatibility") {
t.Fatalf("expected legacy body to be preserved, got %q", definition.Agent.Body) 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.Skills) != 0 ||
len(definition.Agent.Frontmatter.MCPServers) != 0 || len(definition.Agent.Frontmatter.MCPServers) != 0 ||
len(definition.Agent.Frontmatter.Fields) != 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,
)
} }
} }

341
pkg/agent/discovery.go Normal file
View file

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

211
pkg/agent/discovery_test.go Normal file
View file

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

View file

@ -275,7 +275,13 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) {
resultCh := make(chan string, 1) resultCh := make(chan string, 1)
go func() { 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 resultCh <- resp
}() }()
@ -338,7 +344,11 @@ func TestAgentLoop_EmitsSteeringAndSkippedToolEvents(t *testing.T) {
t.Fatalf("expected steering interrupt kind, got %q", interruptPayload.Kind) t.Fatalf("expected steering interrupt kind, got %q", interruptPayload.Kind)
} }
if interruptPayload.ContentLen != len("change course") { 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{ provider := &failFirstMockProvider{
failures: 1, failures: 1,
failError: contextErr, 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() t.Helper()
timer := time.NewTimer(timeout) timer := time.NewTimer(timeout)

View file

@ -40,7 +40,11 @@ func (h *builtinAutoHook) AfterLLM(
return next, HookDecision{Action: HookActionModify}, nil 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() t.Helper()
cfg := &config.Config{ cfg := &config.Config{
@ -102,7 +106,13 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsBuiltinHook(t *testing.T)
}) })
defer al.Close() 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 { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -140,7 +150,13 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T)
}) })
defer al.Close() 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 { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -172,7 +188,13 @@ func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testin
}) })
defer al.Close() 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 { if err == nil {
t.Fatal("expected invalid configured hook error") t.Fatal("expected invalid configured hook error")
} }

View file

@ -98,7 +98,11 @@ type processHookAfterToolResponse struct {
Result *ToolResultHookResponse `json:"result,omitempty"` 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 { if len(opts.Command) == 0 {
return nil, fmt.Errorf("process hook command is required") 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 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 { if ph == nil || !ph.opts.ApproveTool {
return ApprovalDecision{Approved: true}, nil 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 { if al == nil {
return fmt.Errorf("agent loop is nil") return fmt.Errorf("agent loop is nil")
} }

View file

@ -79,8 +79,14 @@ type LLMInterceptor interface {
} }
type ToolInterceptor interface { type ToolInterceptor interface {
BeforeTool(ctx context.Context, call *ToolCallHookRequest) (*ToolCallHookRequest, HookDecision, error) BeforeTool(
AfterTool(ctx context.Context, result *ToolResultHookResponse) (*ToolResultHookResponse, HookDecision, error) ctx context.Context,
call *ToolCallHookRequest,
) (*ToolCallHookRequest, HookDecision, error)
AfterTool(
ctx context.Context,
result *ToolResultHookResponse,
) (*ToolResultHookResponse, HookDecision, error)
} }
type ToolApprover interface { 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 { if hm == nil || req == nil {
return req, HookDecision{Action: HookActionContinue} return req, HookDecision{Action: HookActionContinue}
} }
@ -326,7 +335,10 @@ func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLM
return current, HookDecision{Action: HookActionContinue} 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 { if hm == nil || resp == nil {
return resp, HookDecision{Action: HookActionContinue} return resp, HookDecision{Action: HookActionContinue}
} }

View file

@ -293,7 +293,10 @@ func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) {
type denyApprovalHook struct{} 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{ return ApprovalDecision{
Approved: false, Approved: false,
Reason: "blocked", Reason: "blocked",

View file

@ -72,12 +72,16 @@ func NewAgentInstance(
// Compile path whitelist patterns from config. // Compile path whitelist patterns from config.
allowReadPaths := buildAllowReadPatterns(cfg) allowReadPaths := buildAllowReadPatterns(cfg)
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
agentToolAllowlist := resolveAgentToolAllowlist(agentCfg)
toolsRegistry := tools.NewToolRegistry() toolsRegistry := tools.NewToolRegistry()
toolsRegistry.SetAllowlist(agentToolAllowlist)
if cfg.Tools.IsToolEnabled("read_file") { if cfg.Tools.IsToolEnabled("read_file") {
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize 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") { if cfg.Tools.IsToolEnabled("write_file") {
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
@ -180,8 +184,15 @@ func NewAgentInstance(
if len(resolved) > 0 { if len(resolved) > 0 {
lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace) lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace)
if err != nil { if err != nil {
logger.WarnCF("agent", "Routing light model config invalid; routing disabled", logger.WarnCF(
map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()}) "agent",
"Routing light model config invalid; routing disabled",
map[string]any{
"light_model": rc.LightModel,
"agent_id": agentID,
"error": err.Error(),
},
)
} else { } else {
lp, _, err := providers.CreateProviderFromConfig(lightModelCfg) lp, _, err := providers.CreateProviderFromConfig(lightModelCfg)
if err != nil { if err != nil {
@ -234,7 +245,8 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
return expandHome(strings.TrimSpace(agentCfg.Workspace)) return expandHome(strings.TrimSpace(agentCfg.Workspace))
} }
// Use the configured default workspace (respects PICOCLAW_HOME) // 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) return expandHome(defaults.Workspace)
} }
// For named agents without explicit workspace, use default workspace with agent ID suffix // For named agents without explicit workspace, use default workspace with agent ID suffix

View file

@ -156,7 +156,11 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
} }
if agent.Candidates[0].Provider != tt.wantProvider { 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 { if agent.Candidates[0].Model != tt.wantModel {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel) t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)

View file

@ -192,7 +192,11 @@ func registerSharedTools(
Proxy: cfg.Tools.Web.Proxy, Proxy: cfg.Tools.Web.Proxy,
}) })
if err != nil { 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 { } else if searchTool != nil {
agent.Tools.Register(searchTool) agent.Tools.Register(searchTool)
} }
@ -205,7 +209,11 @@ func registerSharedTools(
cfg.Tools.Web.FetchLimitBytes, cfg.Tools.Web.FetchLimitBytes,
cfg.Tools.Web.PrivateHostWhitelist) cfg.Tools.Web.PrivateHostWhitelist)
if err != 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 { } else {
agent.Tools.Register(fetchTool) agent.Tools.Register(fetchTool)
} }
@ -475,7 +483,12 @@ func (al *AgentLoop) Run(ctx context.Context) error {
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey), "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 { if continueErr != nil {
logger.WarnCF("agent", "Failed to continue queued steering", logger.WarnCF("agent", "Failed to continue queued steering",
map[string]any{ map[string]any{
@ -503,14 +516,22 @@ func (al *AgentLoop) Run(ctx context.Context) error {
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey), "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 { 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{ map[string]any{
"channel": target.Channel, "channel": target.Channel,
"chat_id": target.ChatID, "chat_id": target.ChatID,
"error": continueErr.Error(), "error": continueErr.Error(),
}) },
)
return return
} }
if continued == "" { if continued == "" {
@ -565,11 +586,15 @@ func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, active
msgScope, _, scopeOK := al.resolveSteeringTarget(msg) msgScope, _, scopeOK := al.resolveSteeringTarget(msg)
if !scopeOK || msgScope != activeScope { if !scopeOK || msgScope != activeScope {
if err := al.requeueInboundMessage(msg); err != nil { if err := al.requeueInboundMessage(msg); err != nil {
logger.WarnCF("agent", "Failed to requeue non-steering inbound message", map[string]any{ logger.WarnCF(
"agent",
"Failed to requeue non-steering inbound message",
map[string]any{
"error": err.Error(), "error": err.Error(),
"channel": msg.Channel, "channel": msg.Channel,
"sender_id": msg.SenderID, "sender_id": msg.SenderID,
}) },
)
} }
continue continue
} }
@ -603,7 +628,10 @@ func (al *AgentLoop) Stop() {
al.running.Store(false) 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 == "" { if response == "" {
return return
} }
@ -1053,7 +1081,10 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and // transcribeAudioInMessage resolves audio media refs, transcribes them, and
// replaces audio annotations in msg.Content with the transcribed text. // replaces audio annotations in msg.Content with the transcribed text.
// Returns the (possibly modified) message and true if audio was transcribed. // 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 { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
return msg, false return msg, false
} }
@ -1063,7 +1094,11 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
for _, ref := range msg.Media { for _, ref := range msg.Media {
path, meta, err := al.mediaStore.ResolveWithMeta(ref) path, meta, err := al.mediaStore.ResolveWithMeta(ref)
if err != nil { 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 continue
} }
if !utils.IsAudioFile(meta.Filename, meta.ContentType) { if !utils.IsAudioFile(meta.Filename, meta.ContentType) {
@ -1141,7 +1176,11 @@ func (al *AgentLoop) sendTranscriptionFeedback(
ReplyToMessageID: messageID, ReplyToMessageID: messageID,
}) })
if err != nil { 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) 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() registry := al.GetRegistry()
route := registry.ResolveRoute(routing.RouteInput{ route := registry.ResolveRoute(routing.RouteInput{
Channel: msg.Channel, Channel: msg.Channel,
@ -1358,7 +1399,10 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv
agent = registry.GetDefaultAgent() agent = registry.GetDefaultAgent()
} }
if agent == nil { 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 return route, agent, nil
@ -1683,7 +1727,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
ts.recordPersistedMessage(rootMsg) 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 activeProvider := ts.agent.Provider
if usedLight && ts.agent.LightProvider != nil { if usedLight && ts.agent.LightProvider != nil {
activeProvider = ts.agent.LightProvider activeProvider = ts.agent.LightProvider
@ -2656,12 +2704,15 @@ turnLoop:
} }
if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { 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{ map[string]any{
"agent_id": ts.agent.ID, "agent_id": ts.agent.ID,
"steering_count": len(steerMsgs), "steering_count": len(steerMsgs),
"session_key": ts.sessionKey, "session_key": ts.sessionKey,
}) },
)
pendingMessages = append(pendingMessages, steerMsgs...) pendingMessages = append(pendingMessages, steerMsgs...)
finalContent = "" finalContent = ""
goto turnLoop goto turnLoop
@ -2777,11 +2828,18 @@ func (al *AgentLoop) selectCandidates(
"score": score, "score": score,
"threshold": agent.Router.Threshold(), "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. // 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) newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory) tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100
@ -2815,7 +2873,10 @@ type compressionResult struct {
// prompt is built dynamically by BuildMessages and is NOT stored here. // prompt is built dynamically by BuildMessages and is NOT stored here.
// The compression note is recorded in the session summary so that // The compression note is recorded in the session summary so that
// BuildMessages can include it in the next system prompt. // 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) history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 2 { if len(history) <= 2 {
return compressionResult{}, false return compressionResult{}, false
@ -2968,7 +3029,11 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
} }
// summarizeSession summarizes the conversation history for a session. // 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) ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel() defer cancel()
@ -3320,7 +3385,10 @@ func (al *AgentLoop) applyExplicitSkillCommand(
skillName, ok := agent.ContextBuilder.ResolveSkillName(arg) skillName, ok := agent.ContextBuilder.ResolveSkillName(arg)
if !ok { 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 { if len(parts) < 3 {
@ -3347,7 +3415,10 @@ func (al *AgentLoop) applyExplicitSkillCommand(
return true, false, "" 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() registry := al.GetRegistry()
cfg := al.GetConfig() cfg := al.GetConfig()
rt := &commands.Runtime{ rt := &commands.Runtime{
@ -3391,7 +3462,10 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
} }
rt.GetModelInfo = func() (string, string) { 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) { rt.SwitchModel = func(value string) (string, error) {
value = strings.TrimSpace(value) 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) 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 { if len(nextCandidates) == 0 {
return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) return "", fmt.Errorf("model %q did not resolve to any provider candidates", value)
} }

View file

@ -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 { 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 return nil
} }
@ -76,7 +80,11 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
} }
} }
if !findValidServer { 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 return nil
} }
@ -193,10 +201,14 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
} }
if useRegex { if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) agent.Tools.Register(
tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults),
)
} }
if useBM25 { if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) agent.Tools.Register(
tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults),
)
} }
} }
} }

View file

@ -25,7 +25,11 @@ import (
// Non-image files (documents, audio, video) have their local path injected // 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. // into Content so the agent can access them via file tools like read_file.
// Returns a new slice; original messages are not mutated. // 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 { if store == nil {
return messages return messages
} }

View file

@ -591,7 +591,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
al.SetMediaStore(store) al.SetMediaStore(store)
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} 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") imagePath := filepath.Join(tmpDir, "screen.png")
if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { 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) t.Fatalf("processMessage() error = %v", err)
} }
if response != "" { 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 { if provider.calls != 1 {
t.Fatalf("expected exactly 1 LLM call, got %d", provider.calls) 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 { 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]) t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0])
} }
if len(telegramChannel.sentMedia[0].Parts) != 1 { 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 { select {
@ -660,7 +672,8 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
t.Fatal("expected session history to be saved") t.Fatal("expected session history to be saved")
} }
last := history[len(history)-1] 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) t.Fatalf("expected handled assistant summary in history, got %+v", last)
} }
} }
@ -685,7 +698,9 @@ func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *tes
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
al.SetMediaStore(store) al.SetMediaStore(store)
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} 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") imagePath := filepath.Join(tmpDir, "screen-steering.png")
if err := os.WriteFile(imagePath, []byte("fake screenshot"), 0o644); err != nil { 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) t.Fatalf("expected 2 LLM calls after queued steering, got %d", provider.calls)
} }
if len(telegramChannel.sentMedia) != 1 { 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() store := media.NewFileMediaStore()
al.SetMediaStore(store) al.SetMediaStore(store)
telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} 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() mediaDir := media.TempDir()
if err := os.MkdirAll(mediaDir, 0o700); err != nil { if err := os.MkdirAll(mediaDir, 0o700); err != nil {
@ -766,13 +786,20 @@ func TestProcessMessage_MediaArtifactCanBeForwardedBySendFile(t *testing.T) {
} }
if len(telegramChannel.sentMedia) != 1 { 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]) t.Fatalf("unexpected sent media target: %+v", telegramChannel.sentMedia[0])
} }
if len(telegramChannel.sentMedia[0].Parts) != 1 { 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 { 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 { if err := m.loop.Steer(providers.Message{Role: "user", Content: "what about this instead?"}); err != nil {
return tools.ErrorResult(err.Error()).WithError(err) 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 // Use a short timeout to avoid hanging
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
defer cancel() defer cancel()
@ -1467,7 +1501,10 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) {
t.Fatalf("unexpected /foo reply: %q", fooResp) t.Fatalf("unexpected /foo reply: %q", fooResp)
} }
if provider.calls != 1 { 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{ newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
@ -1617,7 +1654,10 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
} }
if provider.calls != 0 { 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 remoteCalls := 0
remoteModel := "" remoteModel := ""
remoteServer := newChatCompletionTestServer(t, "remote", "remote reply", &remoteCalls, &remoteModel) remoteServer := newChatCompletionTestServer(
t,
"remote",
"remote reply",
&remoteCalls,
&remoteModel,
)
defer remoteServer.Close() defer remoteServer.Close()
cfg := &config.Config{ cfg := &config.Config{
@ -1958,7 +2004,9 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
// Create a provider that fails once with a context error // 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{ provider := &failFirstMockProvider{
failures: 1, failures: 1,
failError: contextErr, failError: contextErr,
@ -2039,7 +2087,13 @@ func TestAgentLoop_EmptyModelResponseUsesAccurateFallback(t *testing.T) {
provider := &simpleMockProvider{response: ""} provider := &simpleMockProvider{response: ""}
al := NewAgentLoop(cfg, msgBus, provider) 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 { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -2071,7 +2125,13 @@ func TestAgentLoop_ToolLimitUsesDedicatedFallback(t *testing.T) {
al := NewAgentLoop(cfg, msgBus, provider) al := NewAgentLoop(cfg, msgBus, provider)
al.RegisterTool(&toolLimitTestTool{}) 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 { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -2389,7 +2449,9 @@ func TestHandleReasoning(t *testing.T) {
break break
} }
if msg.Content == "should timeout" { 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} provider := &toolFeedbackProvider{filePath: heartbeatFile}
al := NewAgentLoop(cfg, msgBus, provider) 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 { if err != nil {
t.Fatalf("ProcessHeartbeat() error = %v", err) t.Fatalf("ProcessHeartbeat() error = %v", err)
} }
@ -2968,8 +3035,14 @@ func TestProcessMessage_ContextOverflowRecovery(t *testing.T) {
agent := al.GetRegistry().GetDefaultAgent() agent := al.GetRegistry().GetDefaultAgent()
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "heavy message"}) agent.Sessions.AddFullMessage(
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "response"}) 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{ response, err := al.processMessage(context.Background(), bus.InboundMessage{

View file

@ -26,7 +26,8 @@ func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool)
return "", false 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 return ensureProtocol(mc.Model), true
} }
@ -78,7 +79,10 @@ func resolvedCandidateProvider(candidates []providers.FallbackCandidate, fallbac
return fallback 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 { if cfg == nil {
return nil, fmt.Errorf("config is nil") return nil, fmt.Errorf("config is nil")
} }

View file

@ -12,6 +12,7 @@ import (
// AgentRegistry manages multiple agent instances and routes messages to them. // AgentRegistry manages multiple agent instances and routes messages to them.
type AgentRegistry struct { type AgentRegistry struct {
cfg *config.Config
agents map[string]*AgentInstance agents map[string]*AgentInstance
resolver *routing.RouteResolver resolver *routing.RouteResolver
mu sync.RWMutex mu sync.RWMutex
@ -23,6 +24,7 @@ func NewAgentRegistry(
provider providers.LLMProvider, provider providers.LLMProvider,
) *AgentRegistry { ) *AgentRegistry {
registry := &AgentRegistry{ registry := &AgentRegistry{
cfg: cfg,
agents: make(map[string]*AgentInstance), agents: make(map[string]*AgentInstance),
resolver: routing.NewRouteResolver(cfg), 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 return registry
} }
@ -130,11 +140,13 @@ func (r *AgentRegistry) Close() {
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
if agent, ok := r.agents["main"]; ok { if id := r.defaultAgentIDLocked(); id != "" {
if agent, ok := r.agents[id]; ok {
return agent return agent
} }
for _, agent := range r.agents { }
return agent for id := range r.agents {
return r.agents[id]
} }
return nil return nil
} }

View file

@ -2,8 +2,10 @@ package agent
import ( import (
"context" "context"
"slices"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
@ -200,6 +202,77 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
agent, _ := registry.GetAgent("no-fallback") agent, _ := registry.GetAgent("no-fallback")
if len(agent.Fallbacks) != 0 { 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())
} }
} }

View file

@ -325,7 +325,10 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance {
// user has since enqueued steering messages. // user has since enqueued steering messages.
// //
// If no steering messages are pending, it returns an empty string. // 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 { if active := al.GetActiveTurn(); active != nil {
return "", fmt.Errorf("turn %s is still active", active.TurnID) return "", fmt.Errorf("turn %s is still active", active.TurnID)
} }

View file

@ -896,7 +896,10 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) {
defer cancelNoExtra() defer cancelNoExtra()
select { select {
case out2 := <-msgBus.OutboundChan(): 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(): case <-noExtraCtx.Done():
} }
@ -1044,7 +1047,11 @@ func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
if err = os.WriteFile(pngPath, pngHeader, 0o644); err != nil { if err = os.WriteFile(pngPath, pngHeader, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err) 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 { if err != nil {
t.Fatalf("Store failed: %v", err) 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) t.Fatalf("expected 2 provider calls, got %d", calls)
} }
if terminalToolsCount != 0 { 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 foundHint := false
@ -1247,7 +1257,8 @@ func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) {
if msg.Role == "user" && msg.Content == expectedHint { if msg.Role == "user" && msg.Content == expectedHint {
foundHint = true 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 foundSkipped = true
} }
} }
@ -1539,7 +1550,8 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) {
foundSkipped := false foundSkipped := false
for _, m := range msgs { 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 foundSkipped = true
break break
} }
@ -1547,7 +1559,13 @@ func TestAgentLoop_Steering_SkippedToolsHaveErrorResults(t *testing.T) {
if !foundSkipped { if !foundSkipped {
// Log what we actually got // Log what we actually got
for i, m := range msgs { 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") t.Fatal("expected skipped tool result for call_2")
} }

View file

@ -505,7 +505,12 @@ func spawnSubTurn(
// Event emissions: // Event emissions:
// - SubTurnResultDeliveredEvent: successful delivery to channel // - SubTurnResultDeliveredEvent: successful delivery to channel
// - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full) // - 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. // 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. // We use defer/recover to catch any unlikely channel panics if it were ever closed.
defer func() { defer func() {
@ -516,9 +521,14 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re
"recover": r, "recover": r,
}) })
if result != nil && al != nil { if result != nil && al != nil {
al.emitEvent(EventKindSubTurnOrphan, al.emitEvent(
EventKindSubTurnOrphan,
parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), 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 parent turn has already finished, treat this as an orphan result
if isFinished || resultChan == nil { if isFinished || resultChan == nil {
if result != nil && al != nil { if result != nil && al != nil {
al.emitEvent(EventKindSubTurnOrphan, al.emitEvent(
EventKindSubTurnOrphan,
parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"), parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"),
SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"}, SubTurnOrphanPayload{
ParentTurnID: parentTS.turnID,
ChildTurnID: childID,
Reason: "parent_finished",
},
) )
} }
return return

View file

@ -571,7 +571,8 @@ func TestHardAbortSessionRollback(t *testing.T) {
} }
// Verify the content matches the initial state // 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") t.Error("history content does not match initial state after rollback")
} }
} }
@ -1290,7 +1291,12 @@ func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) {
finalOrphan := orphanCount finalOrphan := orphanCount
mu.Unlock() 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 // With the new drainPendingResults behavior, the total events may be >= numResults
// because Finish() drains remaining results from the channel and emits them as orphans. // because Finish() drains remaining results from the channel and emits them as orphans.

View file

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

View file

@ -248,6 +248,7 @@ type AgentConfig struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"` Workspace string `json:"workspace,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"` Model *AgentModelConfig `json:"model,omitempty"`
Tools []string `json:"tools,omitempty"`
Skills []string `json:"skills,omitempty"` Skills []string `json:"skills,omitempty"`
Subagents *SubagentsConfig `json:"subagents,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"`
} }
@ -843,13 +844,13 @@ type WebToolsConfig struct {
// the client-side web_search tool is hidden to avoid duplicate search surfaces, // 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 // and the provider's built-in search is used instead. Falls back to client-side
// search when the provider does not support native search. // 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). // 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. // 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"` Proxy string `yaml:"-" json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` FetchLimitBytes int64 `yaml:"-" json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
Format string `json:"format,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FORMAT"` Format string `yaml:"-" json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` PrivateHostWhitelist FlexibleStringSlice `yaml:"-" json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
} }
type CronToolsConfig struct { type CronToolsConfig struct {
@ -999,7 +1000,10 @@ func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { 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 return DefaultConfig(), nil
} }
logger.Errorf("failed to read config file: %v", err) logger.Errorf("failed to read config file: %v", err)
@ -1022,7 +1026,10 @@ func LoadConfig(path string) (*Config, error) {
var cfg *Config var cfg *Config
switch versionInfo.Version { switch versionInfo.Version {
case 0: 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) // Legacy config (no version field)
v, e := loadConfigV0(data) v, e := loadConfigV0(data)
if e != nil { if e != nil {
@ -1030,10 +1037,16 @@ func LoadConfig(path string) (*Config, error) {
} }
cfg, e = v.Migrate() cfg, e = v.Migrate()
if e != nil { 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 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) err = makeBackup(path)
if err != nil { if err != nil {
return nil, err 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 // Load existing security config and merge with migrated one to prevent data loss
secErr := loadSecurityConfig(cfg, securityPath(path)) secErr := loadSecurityConfig(cfg, securityPath(path))
if secErr != nil && !os.IsNotExist(secErr) { 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) return nil, fmt.Errorf("failed to load existing security config: %w", secErr)
} }
defer func(cfg *Config) { defer func(cfg *Config) {

View file

@ -120,6 +120,7 @@ func TestAgentConfig_FullParse(t *testing.T) {
"primary": "claude-opus", "primary": "claude-opus",
"fallbacks": ["haiku"] "fallbacks": ["haiku"]
}, },
"tools": ["read_file", "web_search"],
"subagents": { "subagents": {
"allow_agents": ["sales"] "allow_agents": ["sales"]
} }
@ -171,6 +172,10 @@ func TestAgentConfig_FullParse(t *testing.T) {
if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" { if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" {
t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) 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 { if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {
t.Errorf("support.Subagents = %+v", support.Subagents) 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" { if binding.AgentID != "support" || binding.Match.Channel != "telegram" {
t.Errorf("binding = %+v", binding) 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) 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) t.Fatalf("LoadConfig failed: %v", err)
} }
if loaded.Channels.Telegram.Placeholder.Enabled { 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) t.Fatalf("LoadConfig() error: %v", err)
} }
if cfg.Agents.Defaults.ToolFeedback.Enabled { if cfg.Agents.Defaults.ToolFeedback.Enabled {
t.Fatal("agents.defaults.tool_feedback.enabled should remain false when unset in config file") t.Fatal(
"agents.defaults.tool_feedback.enabled should remain false when unset in config file",
)
} }
} }
@ -764,7 +774,10 @@ func TestDefaultConfig_SummarizationThresholds(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if cfg.Agents.Defaults.SummarizeMessageThreshold != 20 { 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 { if cfg.Agents.Defaults.SummarizeTokenPercent != 75 {
t.Errorf("SummarizeTokenPercent = %d, want 75", cfg.Agents.Defaults.SummarizeTokenPercent) 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") want := filepath.Join("/custom/picoclaw/home", "workspace")
if cfg.Agents.Defaults.Workspace != want { 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) { 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 return
} }
@ -1006,7 +1028,8 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LoadConfig() error = %v", err) 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) t.Fatalf("placeholder.text = %#v, want [\"Thinking...\"]", got)
} }
} }
@ -1196,9 +1219,21 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
cfg := &Config{ cfg := &Config{
Version: CurrentVersion, Version: CurrentVersion,
ModelList: []*ModelConfig{ ModelList: []*ModelConfig{
{ModelName: "plain", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-new-plaintext")}, {
{ModelName: "enc", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings(alreadyEncrypted)}, ModelName: "plain",
{ModelName: "file", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("file://api.key")}, 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 { if err := SaveConfig(cfgPath, cfg); err != nil {
@ -1335,7 +1370,10 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile)) raw, _ := os.ReadFile(filepath.Join(dir, SecurityConfigFile))
if !strings.Contains(string(raw), "enc://") { 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,
)
} }
} }
@ -1588,8 +1626,12 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
// Web tool API keys // Web tool API keys
Web: WebToolsConfig{ Web: WebToolsConfig{
Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}}, Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}},
Tavily: TavilyConfig{APIKeys: SecureStrings{NewSecureString("tavily-api-key")}}, Tavily: TavilyConfig{
Perplexity: PerplexityConfig{APIKeys: SecureStrings{NewSecureString("perplexity-api-key")}}, APIKeys: SecureStrings{NewSecureString("tavily-api-key")},
},
Perplexity: PerplexityConfig{
APIKeys: SecureStrings{NewSecureString("perplexity-api-key")},
},
GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")}, GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")},
BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")}, BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")},
}, },
@ -1597,7 +1639,9 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
Skills: SkillsToolsConfig{ Skills: SkillsToolsConfig{
Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")}, Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")},
Registries: SkillsRegistriesConfig{ Registries: SkillsRegistriesConfig{
ClawHub: ClawHubRegistryConfig{AuthToken: *NewSecureString("clawhub-auth-token")}, ClawHub: ClawHubRegistryConfig{
AuthToken: *NewSecureString("clawhub-auth-token"),
},
}, },
}, },
}, },

View file

@ -65,7 +65,11 @@ func DefaultConfig() *Config {
Enabled: true, Enabled: true,
Text: FlexibleStringSlice{"Thinking... 💭"}, Text: FlexibleStringSlice{"Thinking... 💭"},
}, },
Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200}, Streaming: StreamingConfig{
Enabled: true,
ThrottleSeconds: 3,
MinGrowthChars: 200,
},
UseMarkdownV2: false, UseMarkdownV2: false,
}, },
Feishu: FeishuConfig{ Feishu: FeishuConfig{

View file

@ -335,7 +335,8 @@ func v0ConvertProvidersToModelList(cfg *configV0) []modelConfigV0 {
providerNames: []string{"github_copilot", "copilot"}, providerNames: []string{"github_copilot", "copilot"},
protocol: "github-copilot", protocol: "github-copilot",
buildConfig: func(p providersConfigV0) (modelConfigV0, bool) { 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{}, false
} }
return modelConfigV0{ return modelConfigV0{

View file

@ -72,7 +72,11 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
// CRITICAL: Verify that user's settings are preserved // CRITICAL: Verify that user's settings are preserved
// This was the bug - these settings were lost when Workspace was empty // This was the bug - these settings were lost when Workspace was empty
if cfg.Agents.Defaults.Provider != "openai" { 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 // Old "model" field is migrated to "model_name" field
if cfg.Agents.Defaults.ModelName != "gpt-4o" { 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") t.Errorf("Agent.ID = %q, want %q", cfg.Agents.List[0].ID, "special-agent")
} }
if cfg.Agents.List[0].Workspace != "/special/workspace" { 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 // 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 // OneBot: group_trigger_prefix should be migrated to group_trigger.prefixes
if len(cfg.Channels.OneBot.GroupTrigger.Prefixes) != 2 { 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 { } else {
if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" { if cfg.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
t.Errorf("Prefixes[0] = %q, want %q", 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 // Verify configs are identical
if cfg2.Agents.Defaults.Provider != cfg1.Agents.Defaults.Provider { 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 { 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 { 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) // GetModelName() should return model_name, not model (deprecated)
if cfg.Agents.Defaults.GetModelName() != "deepseek-reasoner" { 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 { if len(cfg.Agents.Defaults.ModelFallbacks) != 1 {

View file

@ -91,7 +91,9 @@ func TestConvertProvidersToModelList_LiteLLM(t *testing.T) {
func TestConvertProvidersToModelList_Multiple(t *testing.T) { func TestConvertProvidersToModelList_Multiple(t *testing.T) {
cfg := &configV0{ cfg := &configV0{
Providers: providersConfigV0{ Providers: providersConfigV0{
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, OpenAI: openAIProviderConfigV0{
providerConfigV0: providerConfigV0{APIKey: "openai-key"},
},
Groq: providerConfigV0{APIKey: "groq-key"}, Groq: providerConfigV0{APIKey: "groq-key"},
Zhipu: providerConfigV0{APIKey: "zhipu-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. // Other providers have no configuration, so they won't be converted.
cfg := &configV0{ cfg := &configV0{
Providers: providersConfigV0{ Providers: providersConfigV0{
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "key1"}}, OpenAI: openAIProviderConfigV0{
LiteLLM: providerConfigV0{APIKey: "key-litellm", APIBase: "http://localhost:4000/v1"}, providerConfigV0: providerConfigV0{APIKey: "key1"},
},
LiteLLM: providerConfigV0{
APIKey: "key-litellm",
APIBase: "http://localhost:4000/v1",
},
Anthropic: providerConfigV0{APIKey: "key2"}, Anthropic: providerConfigV0{APIKey: "key2"},
OpenRouter: providerConfigV0{APIKey: "key3"}, OpenRouter: providerConfigV0{APIKey: "key3"},
Groq: providerConfigV0{APIKey: "key4"}, Groq: providerConfigV0{APIKey: "key4"},
@ -261,7 +268,11 @@ func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
// Should use user's model, not default // Should use user's model, not default
if result[0].Model != "deepseek/deepseek-reasoner" { 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{ Providers: providersConfigV0{
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "sk-openai"}}, OpenAI: openAIProviderConfigV0{
providerConfigV0: providerConfigV0{APIKey: "sk-openai"},
},
DeepSeek: providerConfigV0{APIKey: "sk-deepseek"}, DeepSeek: providerConfigV0{APIKey: "sk-deepseek"},
}, },
} }
@ -391,7 +404,11 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
} }
case "deepseek": case "deepseek":
if mc.Model != "deepseek/deepseek-reasoner" { 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 // ModelName should be the user's model value for backward compatibility
if result[0].ModelName != "glm-4.7" { 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 // Model should use the user's model with protocol prefix
@ -510,7 +531,9 @@ func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testin
}, },
}, },
Providers: providersConfigV0{ Providers: providersConfigV0{
OpenAI: openAIProviderConfigV0{providerConfigV0: providerConfigV0{APIKey: "openai-key"}}, OpenAI: openAIProviderConfigV0{
providerConfigV0: providerConfigV0{APIKey: "openai-key"},
},
Zhipu: providerConfigV0{APIKey: "zhipu-key"}, Zhipu: providerConfigV0{APIKey: "zhipu-key"},
}, },
} }
@ -571,7 +594,11 @@ func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) { func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
result := buildModelWithProtocol("openrouter", "openrouter/auto") result := buildModelWithProtocol("openrouter", "openrouter/auto")
if result != "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 // Model should NOT have duplicated prefix
if result[0].Model != "openrouter/auto" { 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",
)
} }
} }

View file

@ -17,7 +17,11 @@ func TestGetModelConfig_Found(t *testing.T) {
Version: CurrentVersion, Version: CurrentVersion,
ModelList: []*ModelConfig{ ModelList: []*ModelConfig{
{ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")}, {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) { func TestGetModelConfig_Concurrent(t *testing.T) {
cfg := &Config{ cfg := &Config{
ModelList: []*ModelConfig{ 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 err != nil && tt.errMsg != "" {
if !strings.Contains(err.Error(), 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,
)
} }
} }
}) })

View file

@ -117,7 +117,10 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
ModelName: "gpt-4", ModelName: "gpt-4",
Model: "openai/gpt-4o", 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"} modelCfg.Fallbacks = []string{"claude-3"}
models := []*ModelConfig{modelCfg} models := []*ModelConfig{modelCfg}
@ -196,7 +199,10 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
RequestTimeout: 30, RequestTimeout: 30,
ThinkingLevel: "high", 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} models := []*ModelConfig{modelCfg}
result := expandMultiKeyModels(models) result := expandMultiKeyModels(models)

View file

@ -304,11 +304,13 @@ func (s *SecureString) UnmarshalJSON(value []byte) error {
func (s SecureString) MarshalYAML() (any, error) { func (s SecureString) MarshalYAML() (any, error) {
// Preserve raw value if it is already a reference (enc:// or file://) // 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 return s.raw, nil
} }
// If resolved is a reference format (e.g. set via Set), copy back to raw // 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 s.raw = s.resolved
return s.raw, nil return s.raw, nil
} }

View file

@ -35,7 +35,10 @@ func TestJSONUnmarshalPrivateFields(t *testing.T) {
t.Errorf("PublicField = %q, want 'pub'", s.PublicField) t.Errorf("PublicField = %q, want 'pub'", s.PublicField)
} }
if s.privateField != "" { 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 // Verify Channel tokens via Key() methods
// Telegram // 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()) t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String())
// Feishu // Feishu
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String()) 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_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 AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String())
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String()) t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String())
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String()) t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String())
@ -383,7 +394,11 @@ skills:
// LINE // LINE
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String()) 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 ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String())
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.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()) 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()) 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.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String())
t.Log("All security keys are successfully accessible via their respective Key() methods") t.Log("All security keys are successfully accessible via their respective Key() methods")

View file

@ -15,7 +15,10 @@ import (
// JobExecutor is the interface for executing cron jobs through the agent // JobExecutor is the interface for executing cron jobs through the agent
type JobExecutor interface { 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 // PublishResponseIfNeeded sends response to the outbound bus only when the
// agent did not already deliver content through the message tool in this round. // agent did not already deliver content through the message tool in this round.
PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string)
@ -34,8 +37,13 @@ type CronTool struct {
// NewCronTool creates a new CronTool // NewCronTool creates a new CronTool
// execTimeout: 0 means no timeout, >0 sets the timeout duration // execTimeout: 0 means no timeout, >0 sets the timeout duration
func NewCronTool( func NewCronTool(
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, cronService *cron.CronService,
execTimeout time.Duration, config *config.Config, executor JobExecutor,
msgBus *bus.MessageBus,
workspace string,
restrict bool,
execTimeout time.Duration,
config *config.Config,
) (*CronTool, error) { ) (*CronTool, error) {
allowCommand := true allowCommand := true
execEnabled := true execEnabled := true
@ -156,7 +164,9 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
chatID := ToolChatID(ctx) chatID := ToolChatID(ctx)
if channel == "" || chatID == "" { 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) 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) // Validate type parameter (server-side whitelist, not just LLM schema hint)
msgType, _ := args["type"].(string) msgType, _ := args["type"].(string)
if msgType != "" && msgType != "message" && msgType != "directive" { 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 // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When

View file

@ -49,7 +49,11 @@ func (s *stubJobExecutor) PublishResponseIfNeeded(
s.publishedChatID = chatID 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() t.Helper()
storePath := filepath.Join(t.TempDir(), "cron.json") storePath := filepath.Join(t.TempDir(), "cron.json")
cronService := cron.NewCronService(storePath, nil) cronService := cron.NewCronService(storePath, nil)
@ -102,7 +106,10 @@ func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) {
}) })
if result.IsError { 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") { if !strings.Contains(result.ForLLM, "Cron job added") {
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
@ -190,7 +197,10 @@ func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) {
}) })
if result.IsError { 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") { if !strings.Contains(result.ForLLM, "Cron job added") {
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
@ -225,7 +235,10 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
}) })
if result.IsError { 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) t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey)
} }
if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" { 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" { if executor.lastPrompt != "send me a poem" {
t.Fatalf("prompt = %q, want original message", executor.lastPrompt) 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) t.Fatalf("published response = %q, want generated reply", executor.publishedResp)
} }
if executor.publishedChan != "telegram" || executor.publishedChatID != "chat-1" { 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 != "" { 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 == "" { 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" { if executor.publishedResp != "agent processed" {
t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed") t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed")

View file

@ -16,7 +16,11 @@ type EditFileTool struct {
} }
// NewEditFileTool creates a new EditFileTool with optional directory restriction. // 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 var patterns []*regexp.Regexp
if len(allowPaths) > 0 { if len(allowPaths) > 0 {
patterns = allowPaths[0] patterns = allowPaths[0]
@ -79,7 +83,11 @@ type AppendFileTool struct {
fs fileSystem 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 var patterns []*regexp.Regexp
if len(allowPaths) > 0 { if len(allowPaths) > 0 {
patterns = allowPaths[0] patterns = allowPaths[0]
@ -166,7 +174,10 @@ func replaceEditContent(content []byte, oldText, newText string) ([]byte, error)
count := strings.Count(contentStr, oldText) count := strings.Count(contentStr, oldText)
if count > 1 { 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) newContent := strings.Replace(contentStr, oldText, newText, 1)

View file

@ -76,7 +76,8 @@ func TestEditTool_EditFile_NotFound(t *testing.T) {
} }
// Should mention file not found // 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) 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 // 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) t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM)
} }
} }

View file

@ -20,7 +20,11 @@ import (
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow 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 == "" { if workspace == "" {
return path, fmt.Errorf("workspace is not defined") return path, fmt.Errorf("workspace is not defined")
} }
@ -483,7 +487,11 @@ type WriteFileTool struct {
fs fileSystem 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 var patterns []*regexp.Regexp
if len(allowPaths) > 0 { if len(allowPaths) > 0 {
patterns = allowPaths[0] patterns = allowPaths[0]
@ -536,7 +544,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
if !overwrite { if !overwrite {
if _, err := t.fs.Open(path); err == nil { 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),
)
} }
} }

View file

@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
} }
// Should contain error message // Should contain error message
if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { if !strings.Contains(result.ForLLM, "failed to open file") &&
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) !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 // 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) 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", "content": "replaced in sandbox",
"overwrite": true, "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)) data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err) assert.NoError(t, err)
@ -325,7 +336,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
} }
// Should list files and directories // 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) t.Errorf("Expected files in listing, got: %s", result.ForLLM)
} }
if !strings.Contains(result.ForLLM, "subdir") { if !strings.Contains(result.ForLLM, "subdir") {
@ -349,8 +361,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
} }
// Should contain error message // Should contain error message
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { if !strings.Contains(result.ForLLM, "failed to read") &&
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) !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 // os.Root might return different errors depending on platform/implementation
// but it definitely should error. // but it definitely should error.
// Our wrapper returns "access denied or file not found" // 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") { !strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) 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) // 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 // 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: // 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))} patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) 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 { if !result.IsError {
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
} }

View file

@ -65,7 +65,9 @@ func (t *I2CTool) Parameters() map[string]any {
func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
if runtime.GOOS != "linux" { 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) action, ok := args["action"].(string)
@ -83,7 +85,9 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult
case "write": case "write":
return t.writeDevice(args) return t.writeDevice(args)
default: 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),
)
} }
} }

View file

@ -55,7 +55,12 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool {
size: i2cSmbusQuick, size: i2cSmbusQuick,
data: nil, 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 return errno == 0
} }
@ -67,7 +72,12 @@ func smbusProbe(fd int, addr int, hasQuick bool) bool {
size: i2cSmbusByte, size: i2cSmbusByte,
data: &data, 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 return errno == 0
} }
@ -83,16 +93,29 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
devPath := fmt.Sprintf("/dev/i2c-%s", bus) devPath := fmt.Sprintf("/dev/i2c-%s", bus)
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
if err != nil { 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) defer syscall.Close(fd)
// Query adapter capabilities to determine available probe methods. // Query adapter capabilities to determine available probe methods.
// I2C_FUNCS writes an unsigned long, which is word-sized on Linux. // I2C_FUNCS writes an unsigned long, which is word-sized on Linux.
var funcs uintptr 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 { 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 hasQuick := funcs&i2cFuncSmbusQuick != 0
@ -100,7 +123,10 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
if !hasQuick && !hasReadByte { if !hasQuick && !hasReadByte {
return ErrorResult( 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 { 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{ result, _ := json.MarshalIndent(map[string]any{

View file

@ -314,7 +314,10 @@ func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Cont
return result 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 { if content == nil || content.Resource == nil {
return "", "[MCP returned an embedded resource without data.]" return "", "[MCP returned an embedded resource without data.]"
} }
@ -374,23 +377,39 @@ func (t *MCPTool) storeBinaryContent(
dir := media.TempDir() dir := media.TempDir()
if err := os.MkdirAll(dir, 0o700); err != nil { 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) ext := extensionForMIMEType(mimeType)
tmpFile, err := os.CreateTemp(dir, "mcp-*"+ext) tmpFile, err := os.CreateTemp(dir, "mcp-*"+ext)
if err != nil { 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() tmpPath := tmpFile.Name()
if _, err = tmpFile.Write(data); err != nil { if _, err = tmpFile.Write(data); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
_ = os.Remove(tmpPath) _ = 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 { if err = tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath) _ = 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( scope := fmt.Sprintf(
@ -470,7 +489,10 @@ func summarizeEmbeddedResource(content *mcp.EmbeddedResource) string {
normalizedMIMEType(resource.MIMEType), 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 { func annotationsAllowUser(annotations *mcp.Annotations) bool {

View file

@ -571,7 +571,10 @@ func TestMCPTool_Execute_EmbeddedResourceBlobStoredAsMedia(t *testing.T) {
result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil) result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil)
if len(result.Media) != 1 { 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]) path, _, err := store.ResolveWithMeta(result.Media[0])
if err != nil { if err != nil {

View file

@ -43,7 +43,10 @@ func TestMessageTool_Execute_Success(t *testing.T) {
// - ForLLM contains send status description // - ForLLM contains send status description
if result.ForLLM != "Message sent to test-channel:test-chat-id" { 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) // - ForUser is empty (user already received message directly)
@ -88,7 +91,10 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
t.Error("Expected Silent=true") t.Error("Expected Silent=true")
} }
if result.ForLLM != "Message sent to custom-channel:custom-chat-id" { 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,
)
} }
} }

View file

@ -215,28 +215,43 @@ func storeInlineDataURL(
payload = strings.NewReplacer("\n", "", "\r", "", "\t", "", " ", "").Replace(payload) payload = strings.NewReplacer("\n", "", "\r", "", "\t", "", " ", "").Replace(payload)
decoded, err := base64.StdEncoding.DecodeString(payload) decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil { 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() dir := media.TempDir()
if err = os.MkdirAll(dir, 0o700); err != nil { 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) ext := extensionForMIMEType(mimeType)
tmpFile, err := os.CreateTemp(dir, "tool-inline-*"+ext) tmpFile, err := os.CreateTemp(dir, "tool-inline-*"+ext)
if err != nil { 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() tmpPath := tmpFile.Name()
if _, err = tmpFile.Write(decoded); err != nil { if _, err = tmpFile.Write(decoded); err != nil {
tmpFile.Close() tmpFile.Close()
_ = os.Remove(tmpPath) _ = 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 { if err = tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath) _ = 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 filename := sanitizeIdentifierComponent(toolName) + ext
@ -255,7 +270,10 @@ func storeInlineDataURL(
}, scope) }, scope)
if err != nil { if err != nil {
_ = os.Remove(tmpPath) _ = 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) return ref, fmt.Sprintf(inlineMediaStoredMessage, mimeType)

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"sort" "sort"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -24,6 +25,7 @@ type ToolRegistry struct {
mu sync.RWMutex mu sync.RWMutex
version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
mediaStore media.MediaStore mediaStore media.MediaStore
allowlist map[string]struct{}
} }
type mediaStoreAware interface { 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) { func (r *ToolRegistry) Register(tool Tool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
name := tool.Name() 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 { if _, exists := r.tools[name]; exists {
logger.WarnCF("tools", "Tool registration overwrites existing tool", logger.WarnCF("tools", "Tool registration overwrites existing tool",
map[string]any{"name": name}) map[string]any{"name": name})
@ -61,6 +93,14 @@ func (r *ToolRegistry) RegisterHidden(tool Tool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
name := tool.Name() 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 { if _, exists := r.tools[name]; exists {
logger.WarnCF("tools", "Hidden tool registration overwrites existing tool", logger.WarnCF("tools", "Hidden tool registration overwrites existing tool",
map[string]any{"name": name}) map[string]any{"name": name})
@ -128,6 +168,14 @@ func (r *ToolRegistry) Version() uint64 {
return r.version.Load() 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 // HiddenToolSnapshot holds a consistent snapshot of hidden tools and the
// registry version at which it was taken. Used by BM25SearchTool cache. // registry version at which it was taken. Used by BM25SearchTool cache.
type HiddenToolSnapshot struct { type HiddenToolSnapshot struct {
@ -203,7 +251,9 @@ func (r *ToolRegistry) ExecuteWithContext(
map[string]any{ map[string]any{
"tool": name, "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. // 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)), tools: make(map[string]*ToolEntry, len(r.tools)),
mediaStore: r.mediaStore, 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 { for name, entry := range r.tools {
clone.tools[name] = &ToolEntry{ clone.tools[name] = &ToolEntry{
Tool: entry.Tool, Tool: entry.Tool,
@ -417,7 +473,10 @@ func (r *ToolRegistry) GetSummaries() []string {
continue 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 return summaries
} }

View file

@ -44,7 +44,11 @@ type mockAsyncRegistryTool struct {
lastCB AsyncCallback 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 m.lastCB = cb
return m.result 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) { func TestToolRegistry_Get_NotFound(t *testing.T) {
r := NewToolRegistry() r := NewToolRegistry()
_, ok := r.Get("nonexistent") _, 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) t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
} }
if got.Function.Description != want.Function.Description { 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()) t.Errorf("expected parent to have 4 tools, got %d", r.Count())
} }
if clone.Count() != 3 { 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 { if _, ok := clone.Get("spawn"); ok {
t.Error("expected clone NOT to have 'spawn' tool registered on parent after cloning") 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: 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 { if result.ForLLM != largeBase64OmittedMessage {
t.Fatalf("expected sanitized payload, got %q", result.ForLLM) t.Fatalf("expected sanitized payload, got %q", result.ForLLM)
@ -688,7 +728,14 @@ func TestToolRegistry_ExecuteWithContext_ExtractsInlineMediaDataURL(t *testing.T
result: SilentResult(payload), 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 { if len(result.Media) != 1 {
t.Fatalf("expected 1 media ref, got %d", len(result.Media)) 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: 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") { if strings.Contains(result.ForLLM, "data:image/png;base64") {
t.Fatalf("expected inline data URL to be removed from ForLLM, got %q", result.ForLLM) t.Fatalf("expected inline data URL to be removed from ForLLM, got %q", result.ForLLM)

View file

@ -80,7 +80,10 @@ func (tr *ToolResult) ContentForLLM() string {
} }
} }
if len(tr.ArtifactTags) > 0 { 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 == "" { if content == "" {
content = artifactNote content = artifactNote
} else if !strings.Contains(content, artifactNote) { } else if !strings.Contains(content, artifactNote) {

View file

@ -142,7 +142,11 @@ func TestToolResultJSONSerialization(t *testing.T) {
t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM) t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM)
} }
if decoded.ForUser != tt.result.ForUser { 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 { if decoded.Silent != tt.result.Silent {
t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent) t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent)

View file

@ -56,19 +56,38 @@ func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *Too
} }
if len(pattern) > MaxRegexPatternLength { if len(pattern) > MaxRegexPatternLength {
logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)}) logger.WarnCF(
return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength)) "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}) logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern})
res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) res, err := t.registry.SearchRegex(pattern, t.maxSearchResults)
if err != nil { if err != nil {
logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()}) logger.WarnCF(
return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) "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) 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) return formatDiscoveryResponse(t.registry, results, t.ttl)
} }
@ -150,7 +173,10 @@ type ToolSearchResult struct {
Description string `json:"description"` 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 { if maxSearchResults <= 0 {
return nil, nil return nil, nil
} }
@ -188,7 +214,11 @@ func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]Tool
return results, nil 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 { if len(results) == 0 {
return SilentResult("No tools found matching the query.") return SilentResult("No tools found matching the query.")
} }
@ -274,7 +304,11 @@ func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine {
cached := &bm25CachedEngine{engine: buildBM25Engine(docs)} cached := &bm25CachedEngine{engine: buildBM25Engine(docs)}
t.cachedEngine = cached t.cachedEngine = cached
t.cacheVersion = snap.Version 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 return cached
} }

View file

@ -93,7 +93,10 @@ func TestRegexSearchTool_Execute(t *testing.T) {
reg.mu.RLock() reg.mu.RLock()
defer reg.mu.RUnlock() defer reg.mu.RUnlock()
if reg.tools["mcp_read_file"].TTL != 5 { 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 { if reg.tools["mcp_fetch_net"].TTL != 0 {
t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)") t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)")

View file

@ -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 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. // detectMediaType determines the MIME type of a file.

View file

@ -79,7 +79,11 @@ func TestSendFileTool_FileTooLarge(t *testing.T) {
func TestSendFileTool_DefaultMaxSize(t *testing.T) { func TestSendFileTool_DefaultMaxSize(t *testing.T) {
tool := NewSendFileTool("/tmp", false, 0, nil) tool := NewSendFileTool("/tmp", false, 0, nil)
if tool.maxFileSize != config.DefaultMaxMediaSize { 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) }) t.Cleanup(func() { _ = os.Remove(testPath) })
pattern := regexp.MustCompile( 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() store := media.NewFileMediaStore()

View file

@ -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...) return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...)
} }
@ -194,7 +198,15 @@ func (t *ExecTool) Parameters() map[string]any {
"properties": map[string]any{ "properties": map[string]any{
"action": map[string]any{ "action": map[string]any{
"type": "string", "type": "string",
"enum": []string{"run", "list", "poll", "read", "write", "kill", "send-keys"}, "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)", "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{ "command": map[string]any{
@ -300,7 +312,12 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
cwd := t.workingDir cwd := t.workingDir
if wd, ok := args["cwd"].(string); ok && wd != "" { if wd, ok := args["cwd"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" { 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 { if err != nil {
return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") 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 { if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir {
resolved, err := filepath.EvalSymlinks(cwd) resolved, err := filepath.EvalSymlinks(cwd)
if err != nil { 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) { if isAllowedPath(resolved, t.allowedPathPatterns) {
cwd = resolved cwd = resolved
@ -364,7 +383,14 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
var cmd *exec.Cmd var cmd *exec.Cmd
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) cmd = exec.CommandContext(
cmdCtx,
"powershell",
"-NoProfile",
"-NonInteractive",
"-Command",
command,
)
} else { } else {
cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) cmd = exec.CommandContext(cmdCtx, "sh", "-c", command)
} }
@ -442,7 +468,10 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
maxLen := 10000 maxLen := 10000
if len(output) > maxLen { 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 { 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() sessionID := generateSessionID()
session := &ProcessSession{ session := &ProcessSession{
ID: sessionID, ID: sessionID,
@ -553,7 +586,8 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
n, err := session.ptyMaster.Read(buf) n, err := session.ptyMaster.Read(buf)
if n > 0 { if n > 0 {
raw := string(buf[:n]) 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) session.SetPtyKeyMode(mode)
} }
@ -734,12 +768,16 @@ func (t *ExecTool) executeWrite(args map[string]any) *ToolResult {
} }
if session.IsDone() { 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 err := session.Write(data); err != nil {
if errors.Is(err, ErrSessionDone) { 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)) 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() { 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 { if err := session.Kill(); err != nil {
@ -992,12 +1032,16 @@ func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult {
} }
if session.IsDone() { 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 err := session.Write(data); err != nil {
if errors.Is(err, ErrSessionDone) { 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)) return ErrorResult(fmt.Sprintf("failed to send keys: %v", err))
} }

View file

@ -100,8 +100,13 @@ func TestShellTool_Timeout(t *testing.T) {
} }
// Should mention timeout // Should mention timeout
if !strings.Contains(result.ForLLM, "timed out") && !strings.Contains(result.ForUser, "timed out") { if !strings.Contains(result.ForLLM, "timed out") &&
t.Errorf("Expected timeout message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) !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") { 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") t.Errorf("Expected kill command to be blocked")
} }
if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "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 { 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") { if !strings.Contains(result.ForLLM, "blocked") {
t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
@ -444,7 +460,10 @@ func TestShellTool_DevNullAllowed(t *testing.T) {
} }
for _, cmd := range commands { 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") { if result.IsError && strings.Contains(result.ForLLM, "blocked") {
t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM) 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 { 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 { if !result.IsError {
t.Errorf("expected block device write to be blocked: %s", cmd) 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 { 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") { 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", "command": "git push origin main",
}) })
if result.IsError && strings.Contains(result.ForLLM, "blocked") { 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). // "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}) result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
cancel() cancel()
if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { 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 { 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") { if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
t.Errorf("file:// URI outside workspace should be blocked: %s", cmd) 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 { 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") { 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 { 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") { 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) 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. // The binary is created in /tmp/test_pgroup.c and compiled as part of test setup.
testBinary := "/tmp/test_pgroup" testBinary := "/tmp/test_pgroup"
if _, err := os.Stat(testBinary); os.IsNotExist(err) { 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) tool, err := NewExecTool("", false)
@ -1555,8 +1606,16 @@ func TestDetectPtyKeyMode(t *testing.T) {
{"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI}, {"rmkx only", "\x1b[?1l\x1b>", PtyKeyModeCSI},
{"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI}, {"both smkx first", "\x1b[?1h\x1b=...\x1b[?1l\x1b>", PtyKeyModeCSI},
{"both rmkx first", "\x1b[?1l\x1b>...\x1b[?1h\x1b=", PtyKeyModeSS3}, {"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 smkx", "\x1b[?1h", PtyKeyModeSS3},
{"partial rmkx", "\x1b[?1l", PtyKeyModeCSI}, {"partial rmkx", "\x1b[?1l", PtyKeyModeCSI},
} }

View file

@ -96,7 +96,11 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
if !force { if !force {
if _, err := os.Stat(targetDir); err == nil { if _, err := os.Stat(targetDir); err == nil {
return ErrorResult( 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 { } else {
@ -142,7 +146,9 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"error": rmErr.Error(), "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. // 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. // Build result with moderation warning if suspicious.
var output string var output string
if result.IsSuspicious { 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", output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
slug, result.Version, registry.Name(), targetDir) slug, result.Version, registry.Name(), targetDir)

View file

@ -17,7 +17,10 @@ type FindSkillsTool struct {
// NewFindSkillsTool creates a new FindSkillsTool. // NewFindSkillsTool creates a new FindSkillsTool.
// registryMgr is the shared registry manager (built from config in createToolRegistry). // registryMgr is the shared registry manager (built from config in createToolRegistry).
// cache is the search cache for deduplicating similar queries. // 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{ return &FindSkillsTool{
registryMgr: registryMgr, registryMgr: registryMgr,
cache: cache, cache: cache,

View file

@ -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. // 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)) 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)) return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID))
} }

View file

@ -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"}} { 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}) result := tool.Execute(context.Background(), map[string]any{"task_id": badVal})
if !result.IsError { 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") { if !strings.Contains(result.ForLLM, "task_id must be a string") {
t.Errorf("Expected type-error message, got: %s", result.ForLLM) 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) t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM)
} }
if pos2 > pos10 { 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,
)
} }
} }

View file

@ -69,7 +69,9 @@ func (t *SPITool) Parameters() map[string]any {
func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult {
if runtime.GOOS != "linux" { 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) action, ok := args["action"].(string)
@ -124,7 +126,9 @@ func (t *SPITool) list() *ToolResult {
// parseSPIArgs extracts and validates common SPI parameters // parseSPIArgs extracts and validates common SPI parameters
// //
//nolint:unused // Used by spi_linux.go //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) dev, ok := args["device"].(string)
if !ok || dev == "" { if !ok || dev == "" {
return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)"

View file

@ -38,25 +38,46 @@ type spiTransfer struct {
func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) {
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
if err != nil { 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 // 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 { if errno != 0 {
syscall.Close(fd) syscall.Close(fd)
return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno)) return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno))
} }
// Set bits per word // 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 { if errno != 0 {
syscall.Close(fd) syscall.Close(fd)
return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno)) return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno))
} }
// Set max speed // 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 { if errno != 0 {
syscall.Close(fd) syscall.Close(fd)
return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno)) 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, 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(txBuf)
runtime.KeepAlive(rxBuf) runtime.KeepAlive(rxBuf)
if errno != 0 { if errno != 0 {
@ -174,7 +200,12 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult {
bitsPerWord: bits, 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(txBuf)
runtime.KeepAlive(rxBuf) runtime.KeepAlive(rxBuf)
if errno != 0 { if errno != 0 {

View file

@ -316,7 +316,11 @@ func TestSubagentTool_ForUserTruncation(t *testing.T) {
// ForUser should be truncated to 500 chars + "..." // ForUser should be truncated to 500 chars + "..."
maxUserLen := 500 maxUserLen := 500
if len(result.ForUser) > maxUserLen+3 { // +3 for "..." 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 // ForLLM should have full content

View file

@ -64,7 +64,13 @@ func RunToolLoop(
llmOpts = map[string]any{} llmOpts = map[string]any{}
} }
// 3. Call LLM // 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 { if err != nil {
logger.ErrorCF("toolloop", "LLM call failed", logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{ map[string]any{
@ -148,7 +154,14 @@ func RunToolLoop(
var toolResult *ToolResult var toolResult *ToolResult
if config.Tools != nil { 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 { } else {
toolResult = ErrorResult("No tools available") toolResult = ErrorResult("No tools available")
} }

View file

@ -151,7 +151,10 @@ func TestValidateToolArgs(t *testing.T) {
schema: map[string]any{ schema: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "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"}, args: map[string]any{"color": "red"},
@ -161,7 +164,10 @@ func TestValidateToolArgs(t *testing.T) {
schema: map[string]any{ schema: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "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"}, args: map[string]any{"color": "yellow"},
@ -172,7 +178,10 @@ func TestValidateToolArgs(t *testing.T) {
schema: map[string]any{ schema: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "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"}, args: map[string]any{"color": "green"},
@ -182,7 +191,10 @@ func TestValidateToolArgs(t *testing.T) {
schema: map[string]any{ schema: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "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"}, args: map[string]any{"color": "yellow"},
@ -342,7 +354,11 @@ func TestValidateToolArgs_RegistryIntegration(t *testing.T) {
} }
// Extra property — should fail with validation error // 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 { if !result.IsError {
t.Error("expected validation error for extra property") t.Error("expected validation error for extra property")
} }

View file

@ -54,7 +54,8 @@ func TestWebTool_WebFetch_Success(t *testing.T) {
} }
// ForUser should contain summary // 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) 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) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != 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()},
)
} }
ctx := context.Background() ctx := context.Background()
@ -100,7 +105,11 @@ func TestWebTool_WebFetch_JSON(t *testing.T) {
func TestWebTool_WebFetch_InvalidURL(t *testing.T) { func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != 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()},
)
} }
ctx := context.Background() ctx := context.Background()
@ -125,7 +134,11 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != 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()},
)
} }
ctx := context.Background() ctx := context.Background()
@ -141,7 +154,8 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
} }
// Should mention only http/https allowed // 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) 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) { func TestWebTool_WebFetch_MissingURL(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != 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()},
)
} }
ctx := context.Background() ctx := context.Background()
@ -164,7 +182,8 @@ func TestWebTool_WebFetch_MissingURL(t *testing.T) {
} }
// Should mention URL is required // 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) 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 tool, err := NewWebFetchTool(1000, format, testFetchLimit) // Limit to 1000 chars
if err != 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()},
)
} }
ctx := context.Background() ctx := context.Background()
@ -216,7 +239,10 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
// Text should end with the truncation notice // Text should end with the truncation notice
if text, ok := resultMap["text"].(string); ok { if text, ok := resultMap["text"].(string); ok {
if !strings.HasSuffix(text, "[Content truncated due to size limit]") { 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", tt.contentType) w.Header().Set("Content-Type", tt.contentType)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(tt.body)) w.Write([]byte(tt.body))
})) }),
)
defer server.Close() defer server.Close()
tool, err := NewWebFetchTool(maxChars, tt.format, testFetchLimit) tool, err := NewWebFetchTool(maxChars, tt.format, testFetchLimit)
@ -291,7 +319,11 @@ func TestWebTool_WebFetch_TruncationNotice(t *testing.T) {
} }
if !strings.HasSuffix(text, truncationNotice) { 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 { if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated {
@ -360,7 +392,11 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
// Initialize the tool // Initialize the tool
tool, err := NewWebFetchTool(50000, format, testFetchLimit) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != 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()},
)
} }
// Prepare the arguments pointing to the URL of our local mock server // 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 // Search for the exact error string we set earlier in the Execute method
expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) 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) 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) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != 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()},
)
} }
ctx := context.Background() ctx := context.Background()
@ -718,7 +759,13 @@ func TestWebTool_WebFetch_PrivateHostAllowedByCIDRWhitelist(t *testing.T) {
defer server.Close() defer server.Close()
host, _ := serverHostAndPort(t, server.URL) 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 { if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err) t.Fatalf("Failed to create web fetch tool: %v", err)
} }
@ -753,7 +800,10 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
}) })
if result.IsError { 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) { func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit) tool, err := NewWebFetchTool(50000, format, testFetchLimit)
if err != 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()},
)
} }
ctx := context.Background() ctx := context.Background()
@ -995,9 +1049,19 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
} }
func TestNewWebFetchToolWithProxy(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 { 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 { } else if tool.maxChars != 1024 {
t.Fatalf("maxChars = %d, want %d", 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) tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", format, testFetchLimit, nil)
if err != 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 { if tool.maxChars != 50000 {
@ -1017,7 +1085,13 @@ func TestNewWebFetchToolWithProxy(t *testing.T) {
} }
func TestNewWebFetchToolWithConfig_InvalidPrivateHostWhitelist(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 { if err == nil {
t.Fatal("expected invalid whitelist entry to fail") t.Fatal("expected invalid whitelist entry to fail")
} }
@ -1173,7 +1247,11 @@ func TestWebTool_TavilySearch_RangeMapping(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
"results": []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) // Should not be an error — the retry response is used as-is (403 is a valid HTTP response)
if result.IsError { 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 // Status in the JSON result should reflect the 403
if !strings.Contains(result.ForLLM, "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")) t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
} }
if r.Header.Get("Authorization") != "Bearer test-glm-key" { 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 var payload map[string]any
@ -1534,14 +1618,21 @@ func TestWebTool_GLMSearch_RangeMapping(t *testing.T) {
t.Fatalf("failed to decode payload: %v", err) t.Fatalf("failed to decode payload: %v", err)
} }
if payload["search_recency_filter"] != "oneMonth" { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
"search_result": []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) t.Fatalf("failed to decode payload: %v", err)
} }
if payload["search_recency_filter"] != "week" { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
"references": []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",
},
}, },
}) })
})) }))