diff --git a/docs/configuration.md b/docs/configuration.md index 9c201c787..ab18bcaf5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -248,22 +248,20 @@ In other words: **channel + account form the candidate set; peer/guild/team then ### Agent Tool Allowlist -You can restrict an individual agent to a subset of runtime tools with `agents.list[].tools`. +Per-agent tool declarations live in `AGENT.md` frontmatter, not in `config.json`. -If `tools` is omitted, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed tools for that agent. +If `tools` is omitted from frontmatter, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed runtime tools for that agent. -```json -{ - "agents": { - "list": [ - { - "id": "research", - "name": "Research Agent", - "tools": ["read_file", "write_file", "web_search", "web_fetch", "message"] - } - ] - } -} +```md +--- +name: Research Agent +description: Specialist for web research and in-depth analysis. +tools: [read_file, write_file, web_search, web_fetch, message] +skills: [deep-research] +mcpServers: [web-index] +--- + +You are the research agent. ``` Notes: @@ -271,7 +269,7 @@ Notes: - This is an allowlist, not a preference hint. - Tool names are matched against the runtime tool name 1:1. - Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- The `available_tools` field in Agent Discovery reflects the filtered runtime result. +- `available_tools` in Agent Discovery reflects the filtered runtime result, while `tools` reflects the identity declared in `AGENT.md`. ### Agent Discovery (Automatic) @@ -284,9 +282,12 @@ Each entry includes: | Field | Meaning | |-------|---------| | `id` | Stable agent id | -| `name` | Human-friendly agent name | -| `description` | Short capability summary | -| `model` | Current model used by that agent | +| `name` | Agent identity name from `AGENT.md` frontmatter | +| `description` | Agent identity description from `AGENT.md` frontmatter | +| `tools` | Declared tool identity from `AGENT.md` frontmatter | +| `skills` | Declared skill identity from `AGENT.md` frontmatter | +| `mcpServers` | Declared MCP server identity from `AGENT.md` frontmatter | +| `model` | Declared model from `AGENT.md` frontmatter | | `available_tools` | Tool names currently visible to that agent | | `channels` | Channels that route to that agent | @@ -294,8 +295,8 @@ Important behavior: - The discovery section includes the current agent's own entry, so the model has self-awareness. - `available_tools` is the most important field for delegation. It reflects the tools the target agent can actually use, not just a natural-language description. -- `description` is sourced from `AGENT.md` frontmatter `description` when available, otherwise from the first meaningful paragraph of `AGENT.md`, and finally `SOUL.md`. -- `name` comes from `agents.list[].name` first, then `AGENT.md` frontmatter `name`, then falls back to the agent id. +- Identity fields (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) come from `AGENT.md` frontmatter. +- `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. - `channels` come from routing state: - the default agent exposes enabled channels - other agents expose channels that explicitly bind to them through `bindings` @@ -310,6 +311,9 @@ Example injected shape: "id": "main", "name": "Main Assistant", "description": "Generalist agent for day-to-day requests.", + "tools": ["read_file", "write_file", "exec", "spawn"], + "skills": ["coordination"], + "mcpServers": ["filesystem"], "model": "gpt-4o-mini", "available_tools": ["read_file", "write_file", "exec", "spawn"], "channels": ["telegram", "discord"] @@ -318,6 +322,9 @@ Example injected shape: "id": "research", "name": "Research Agent", "description": "Specialist for long-form investigation and web work.", + "tools": ["read_file", "web_search", "web_fetch", "message"], + "skills": ["deep-research"], + "mcpServers": ["web-index"], "model": "claude-sonnet-4.5", "available_tools": ["web_search", "web_fetch", "read_file"], "channels": ["telegram"] diff --git a/docs/it/configuration.md b/docs/it/configuration.md index 9b0d4a198..ef77f55ab 100644 --- a/docs/it/configuration.md +++ b/docs/it/configuration.md @@ -73,22 +73,20 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ### Allowlist dei Tool per Agent -Puoi limitare un singolo agent a un sottoinsieme di tool runtime con `agents.list[].tools`. +La dichiarazione dei tool per-agent vive nel frontmatter di `AGENT.md`, non in `config.json`. -Se `tools` è omesso, l'agent riceve il normale set globale dei tool abilitati. Se `tools` è presente, PicoClaw registra per quell'agent solo i tool elencati. +Se `tools` è omesso nel frontmatter, l'agent riceve il normale set globale dei tool abilitati. Se `tools` è presente, PicoClaw registra per quell'agent solo i tool runtime elencati. -```json -{ - "agents": { - "list": [ - { - "id": "research", - "name": "Research Agent", - "tools": ["read_file", "write_file", "web_search", "web_fetch", "message"] - } - ] - } -} +```md +--- +name: Research Agent +description: Specialista per ricerca web e analisi approfondita. +tools: [read_file, write_file, web_search, web_fetch, message] +skills: [deep-research] +mcpServers: [web-index] +--- + +Sei l'agent di ricerca. ``` Note: @@ -96,7 +94,7 @@ Note: - È una allowlist reale, non un suggerimento per l'LLM. - I nomi dei tool fanno match 1:1 con il nome runtime del tool. - Se ti serve controllo preciso, usa i nomi runtime effettivi come `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- Il campo `available_tools` nella Agent Discovery riflette il risultato filtrato reale. +- `available_tools` nella Agent Discovery riflette il risultato runtime filtrato, mentre `tools` riflette l'identità dichiarata in `AGENT.md`. ### Discovery Multi-Agent (Automatica) @@ -109,9 +107,12 @@ Ogni entry include: | Campo | Significato | |-------|-------------| | `id` | ID stabile dell'agent | -| `name` | Nome leggibile dell'agent | -| `description` | Riassunto breve delle capacità | -| `model` | Modello attualmente usato da quell'agent | +| `name` | Nome identitario da `AGENT.md` frontmatter | +| `description` | Descrizione identitaria da `AGENT.md` frontmatter | +| `tools` | Tool dichiarati nel frontmatter di `AGENT.md` | +| `skills` | Skill dichiarate nel frontmatter di `AGENT.md` | +| `mcpServers` | Server MCP dichiarati nel frontmatter di `AGENT.md` | +| `model` | Modello dichiarato nel frontmatter di `AGENT.md` | | `available_tools` | Tool attualmente visibili a quell'agent | | `channels` | Canali instradati verso quell'agent | @@ -119,8 +120,8 @@ Dettagli importanti: - La sezione include anche l'entry dell'agent corrente, quindi c'è self-awareness. - `available_tools` è il campo più importante per delegare bene: l'LLM vede i tool reali del peer, non deve indovinarli dalla sola descrizione. -- `description` viene presa da `AGENT.md` frontmatter `description` quando presente; altrimenti dal primo paragrafo utile di `AGENT.md`, e in fallback da `SOUL.md`. -- `name` arriva prima da `agents.list[].name`, poi da `AGENT.md` frontmatter `name`, e in fallback dall'ID dell'agent. +- I campi di identità (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) arrivano dal frontmatter di `AGENT.md`. +- `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. - `channels` derivano dal routing: - l'agent di default espone i canali abilitati - gli altri agent espongono i canali che hanno un binding esplicito verso di loro @@ -135,6 +136,9 @@ Forma dell'oggetto iniettato: "id": "main", "name": "Main Assistant", "description": "Agent generalista per richieste quotidiane.", + "tools": ["read_file", "write_file", "exec", "spawn"], + "skills": ["coordination"], + "mcpServers": ["filesystem"], "model": "gpt-4o-mini", "available_tools": ["read_file", "write_file", "exec", "spawn"], "channels": ["telegram", "discord"] @@ -143,6 +147,9 @@ Forma dell'oggetto iniettato: "id": "research", "name": "Research Agent", "description": "Specialista per investigazioni e lavoro web.", + "tools": ["read_file", "web_search", "web_fetch", "message"], + "skills": ["deep-research"], + "mcpServers": ["web-index"], "model": "claude-sonnet-4.5", "available_tools": ["web_search", "web_fetch", "read_file"], "channels": ["telegram"] diff --git a/pkg/agent/definition.go b/pkg/agent/definition.go index cf73d607c..90a69eaa4 100644 --- a/pkg/agent/definition.go +++ b/pkg/agent/definition.go @@ -35,7 +35,7 @@ type AgentFrontmatter struct { MaxTurns *int `json:"maxTurns,omitempty"` Skills []string `json:"skills,omitempty"` MCPServers []string `json:"mcpServers,omitempty"` - Fields map[string]any `json:"fields,omitempty"` + Fields map[string]any `json:"-"` } // AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file. diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index b630abd60..31b05c635 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "reflect" "sort" "strings" @@ -14,10 +15,8 @@ import ( // AgentDescriptor is the structured discovery payload injected into each // agent's system prompt so the LLM can make concrete delegation decisions. type AgentDescriptor struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Model string `json:"model"` + ID string `json:"id"` + AgentFrontmatter AvailableTools []string `json:"available_tools"` Channels []string `json:"channels"` } @@ -82,21 +81,12 @@ func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bo func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor { definition := loadAgentDefinition(agent.Workspace) - name := strings.TrimSpace(agent.Name) - if name == "" && definition.Agent != nil { - name = strings.TrimSpace(definition.Agent.Frontmatter.Name) - } - if name == "" { - name = agent.ID - } return AgentDescriptor{ - ID: agent.ID, - Name: name, - Description: agentDescriptionFromDefinition(definition), - Model: strings.TrimSpace(agent.Model), - AvailableTools: visibleToolNames(agent), - Channels: r.channelsForAgentLocked(agent.ID), + ID: agent.ID, + AgentFrontmatter: descriptorFrontmatter(agent.ID, definition), + AvailableTools: visibleToolNames(agent), + Channels: r.channelsForAgentLocked(agent.ID), } } @@ -120,21 +110,25 @@ func visibleToolNames(agent *AgentInstance) []string { return names } -func agentDescriptionFromDefinition(definition AgentContextDefinition) string { +func descriptorFrontmatter(agentID string, definition AgentContextDefinition) AgentFrontmatter { + frontmatter := AgentFrontmatter{} if definition.Agent != nil { - if desc := strings.TrimSpace(definition.Agent.Frontmatter.Description); desc != "" { - return desc - } - if desc := firstMeaningfulParagraph(definition.Agent.Body); desc != "" { - return desc - } + frontmatter = definition.Agent.Frontmatter + frontmatter.Tools = append([]string(nil), frontmatter.Tools...) + frontmatter.Skills = append([]string(nil), frontmatter.Skills...) + frontmatter.MCPServers = append([]string(nil), frontmatter.MCPServers...) } - if definition.Soul != nil { - if desc := firstMeaningfulParagraph(definition.Soul.Content); desc != "" { - return desc - } + + if strings.TrimSpace(frontmatter.Name) == "" { + frontmatter.Name = agentID } - return "" + if strings.TrimSpace(frontmatter.Description) == "" && + definition.Source == AgentDefinitionSourceAgents && + definition.Agent != nil { + frontmatter.Description = firstMeaningfulParagraph(definition.Agent.Body) + } + + return frontmatter } func firstMeaningfulParagraph(content string) string { @@ -171,9 +165,10 @@ func firstMeaningfulParagraph(content string) string { func (r *AgentRegistry) channelsForAgentLocked(agentID string) []string { channels := make(map[string]struct{}) + enabled := enabledChannelSet(r.cfg) if defaultID := r.defaultAgentIDLocked(); defaultID != "" && defaultID == agentID { - for _, channel := range enabledChannels(r.cfg) { + for channel := range enabled { channels[channel] = struct{}{} } } @@ -187,6 +182,9 @@ func (r *AgentRegistry) channelsForAgentLocked(agentID string) []string { if channel == "" { continue } + if _, ok := enabled[channel]; !ok { + continue + } channels[channel] = struct{}{} } } @@ -208,58 +206,42 @@ func enabledChannels(cfg *config.Config) []string { return []string{} } - enabled := make([]string, 0, 16) - if cfg.Channels.WhatsApp.Enabled { - enabled = append(enabled, "whatsapp") - } - if cfg.Channels.Telegram.Enabled { - enabled = append(enabled, "telegram") - } - if cfg.Channels.Feishu.Enabled { - enabled = append(enabled, "feishu") - } - if cfg.Channels.Discord.Enabled { - enabled = append(enabled, "discord") - } - if cfg.Channels.MaixCam.Enabled { - enabled = append(enabled, "maixcam") - } - if cfg.Channels.QQ.Enabled { - enabled = append(enabled, "qq") - } - if cfg.Channels.DingTalk.Enabled { - enabled = append(enabled, "dingtalk") - } - if cfg.Channels.Slack.Enabled { - enabled = append(enabled, "slack") - } - if cfg.Channels.Matrix.Enabled { - enabled = append(enabled, "matrix") - } - if cfg.Channels.LINE.Enabled { - enabled = append(enabled, "line") - } - if cfg.Channels.OneBot.Enabled { - enabled = append(enabled, "onebot") - } - if cfg.Channels.WeCom.Enabled { - enabled = append(enabled, "wecom") - } - if cfg.Channels.Weixin.Enabled { - enabled = append(enabled, "weixin") - } - if cfg.Channels.Pico.Enabled { - enabled = append(enabled, "pico") - } - if cfg.Channels.PicoClient.Enabled { - enabled = append(enabled, "pico_client") - } - if cfg.Channels.IRC.Enabled { - enabled = append(enabled, "irc") + value := reflect.ValueOf(cfg.Channels) + typ := value.Type() + enabled := make([]string, 0, typ.NumField()) + for i := 0; i < typ.NumField(); i++ { + fieldValue := value.Field(i) + enabledField := fieldValue.FieldByName("Enabled") + if !enabledField.IsValid() || enabledField.Kind() != reflect.Bool || !enabledField.Bool() { + continue + } + name := jsonFieldName(typ.Field(i).Tag.Get("json")) + if name == "" { + continue + } + enabled = append(enabled, name) } + sort.Strings(enabled) return enabled } +func enabledChannelSet(cfg *config.Config) map[string]struct{} { + channels := enabledChannels(cfg) + result := make(map[string]struct{}, len(channels)) + for _, channel := range channels { + result[channel] = struct{}{} + } + return result +} + +func jsonFieldName(tag string) string { + name := strings.TrimSpace(strings.Split(tag, ",")[0]) + if name == "" || name == "-" { + return "" + } + return name +} + func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string { agent, ok := r.agents[routing.NormalizeAgentID(agentID)] if !ok || agent == nil { @@ -331,7 +313,7 @@ func formatAgentDiscoverySection(currentAgentID string, agents []AgentDescriptor header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") } header.WriteString( - "Delegate based on available_tools first, then model, channels, and description. Use only agent IDs listed here.\n\n", + "Delegate based on available_tools first, then skills, mcpServers, model, channels, and description. Use only agent IDs listed here.\n\n", ) header.WriteString("```json\n") header.Write(encoded) diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go index a44f67dea..83a5472e3 100644 --- a/pkg/agent/discovery_test.go +++ b/pkg/agent/discovery_test.go @@ -13,6 +13,10 @@ func TestAgentRegistry_ListAgentsBuildsStructuredDescriptors(t *testing.T) { "AGENT.md": `--- name: Main Frontmatter Name description: Structured main agent +model: main-frontmatter-model +tools: [read_file, write_file] +skills: [coordination] +mcpServers: [filesystem] --- # Agent @@ -22,21 +26,24 @@ Handle general requests. defer cleanupWorkspace(t, mainWorkspace) supportWorkspace := setupWorkspace(t, map[string]string{ - "AGENT.md": `# Agent + "AGENT.md": `--- +name: Support Frontmatter Name +description: Support frontmatter description +model: support-frontmatter-model +tools: [read_file] +skills: [support-playbook] +mcpServers: [support-db] +--- +# Agent Handle support tickets carefully. `, - "SOUL.md": "# Soul\nStay calm and precise.", }) defer cleanupWorkspace(t, supportWorkspace) cfg := testCfg([]config.AgentConfig{ {ID: "main", Default: true, Name: "Configured Main", Workspace: mainWorkspace}, - { - ID: "support", - Workspace: supportWorkspace, - Model: &config.AgentModelConfig{Primary: "support-model"}, - }, + {ID: "support", Workspace: supportWorkspace}, }) cfg.Tools.ReadFile.Enabled = true cfg.Tools.WriteFile.Enabled = true @@ -61,14 +68,23 @@ Handle support tickets carefully. if descriptors[0].ID != "main" { t.Fatalf("expected current workspace agent first, got %q", descriptors[0].ID) } - if descriptors[0].Name != "Configured Main" { - t.Fatalf("expected config name to win, got %q", descriptors[0].Name) + if descriptors[0].Name != "Main Frontmatter Name" { + t.Fatalf("expected frontmatter name to drive discovery, got %q", descriptors[0].Name) } if descriptors[0].Description != "Structured main agent" { t.Fatalf("expected frontmatter description, got %q", descriptors[0].Description) } - if descriptors[0].Model != "gpt-4" { - t.Fatalf("expected inherited model, got %q", descriptors[0].Model) + if descriptors[0].Model != "main-frontmatter-model" { + t.Fatalf("expected frontmatter model, got %q", descriptors[0].Model) + } + if !slices.Equal(descriptors[0].Tools, []string{"read_file", "write_file"}) { + t.Fatalf("expected declared frontmatter tools, got %v", descriptors[0].Tools) + } + if !slices.Equal(descriptors[0].Skills, []string{"coordination"}) { + t.Fatalf("expected frontmatter skills, got %v", descriptors[0].Skills) + } + if !slices.Equal(descriptors[0].MCPServers, []string{"filesystem"}) { + t.Fatalf("expected frontmatter mcpServers, got %v", descriptors[0].MCPServers) } if !slices.Contains(descriptors[0].AvailableTools, "read_file") || !slices.Contains(descriptors[0].AvailableTools, "write_file") { @@ -85,11 +101,20 @@ Handle support tickets carefully. if !ok || support == nil { t.Fatal("expected support descriptor lookup to succeed") } - if support.Description != "Handle support tickets carefully." { - t.Fatalf("expected AGENT body fallback description, got %q", support.Description) + if support.Name != "Support Frontmatter Name" { + t.Fatalf("expected support frontmatter name, got %q", support.Name) } - if support.Model != "support-model" { - t.Fatalf("expected explicit support model, got %q", support.Model) + if support.Description != "Support frontmatter description" { + t.Fatalf("expected support frontmatter description, got %q", support.Description) + } + if support.Model != "support-frontmatter-model" { + t.Fatalf("expected support frontmatter model, got %q", support.Model) + } + if !slices.Equal(support.Skills, []string{"support-playbook"}) { + t.Fatalf("expected support skills, got %v", support.Skills) + } + if !slices.Equal(support.MCPServers, []string{"support-db"}) { + t.Fatalf("expected support mcpServers, got %v", support.MCPServers) } if !slices.Equal(support.Channels, []string{"telegram"}) { t.Fatalf("expected support channel binding, got %v", support.Channels) @@ -100,6 +125,7 @@ func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- description: Main agent +skills: [coordination] --- # Agent @@ -111,6 +137,8 @@ Generalist. researchWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- description: Research specialist +skills: [deep-research] +mcpServers: [web-index] --- # Agent @@ -162,6 +190,12 @@ Investigate deeply. !strings.Contains(systemPrompt, `"write_file"`) { t.Fatalf("expected visible tool list in discovery section, got %q", systemPrompt) } + if !strings.Contains(systemPrompt, `"skills": [`) || !strings.Contains(systemPrompt, `"deep-research"`) { + t.Fatalf("expected frontmatter skills in discovery section, got %q", systemPrompt) + } + if !strings.Contains(systemPrompt, `"mcpServers": [`) || !strings.Contains(systemPrompt, `"web-index"`) { + t.Fatalf("expected frontmatter mcpServers in discovery section, got %q", systemPrompt) + } } func TestContextBuilder_BuildMessagesOmitsAgentDiscoverySectionForSingleton(t *testing.T) { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 4b3b4b3ee..89bf0416a 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -63,7 +63,9 @@ func NewAgentInstance( workspace := resolveAgentWorkspace(agentCfg, defaults) os.MkdirAll(workspace, 0o755) - model := resolveAgentModel(agentCfg, defaults) + definition := loadAgentDefinition(workspace) + + model := resolveAgentModel(agentCfg, defaults, definition) fallbacks := resolveAgentFallbacks(agentCfg, defaults) restrict := defaults.RestrictToWorkspace @@ -72,7 +74,7 @@ func NewAgentInstance( // Compile path whitelist patterns from config. allowReadPaths := buildAllowReadPatterns(cfg) allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) - agentToolAllowlist := resolveAgentToolAllowlist(agentCfg) + agentToolAllowlist := resolveAgentToolAllowlist(definition) toolsRegistry := tools.NewToolRegistry() toolsRegistry.SetAllowlist(agentToolAllowlist) @@ -125,8 +127,11 @@ func NewAgentInstance( if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) agentName = agentCfg.Name + if definition.Agent != nil && strings.TrimSpace(definition.Agent.Frontmatter.Name) != "" { + agentName = strings.TrimSpace(definition.Agent.Frontmatter.Name) + } subagents = agentCfg.Subagents - skillsFilter = agentCfg.Skills + skillsFilter = resolveAgentSkillsFilter(agentCfg, definition) } maxIter := defaults.MaxToolIterations @@ -255,7 +260,14 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD } // resolveAgentModel resolves the primary model for an agent. -func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { +func resolveAgentModel( + agentCfg *config.AgentConfig, + defaults *config.AgentDefaults, + definition AgentContextDefinition, +) string { + if definition.Agent != nil && strings.TrimSpace(definition.Agent.Frontmatter.Model) != "" { + return strings.TrimSpace(definition.Agent.Frontmatter.Model) + } if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { return strings.TrimSpace(agentCfg.Model.Primary) } @@ -270,6 +282,19 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD return defaults.ModelFallbacks } +func resolveAgentSkillsFilter( + agentCfg *config.AgentConfig, + definition AgentContextDefinition, +) []string { + if definition.Agent != nil && definition.Agent.Frontmatter.Skills != nil { + return append([]string(nil), definition.Agent.Frontmatter.Skills...) + } + if agentCfg == nil || agentCfg.Skills == nil { + return nil + } + return append([]string(nil), agentCfg.Skills...) +} + func compilePatterns(patterns []string) []*regexp.Regexp { compiled := make([]*regexp.Regexp, 0, len(patterns)) for _, p := range patterns { diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index e296a18cb..aedda1c32 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -281,3 +281,42 @@ func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { t.Fatal("read_file tool should still be registered") } } + +func TestNewAgentInstance_UsesFrontmatterModelAndSkills(t *testing.T) { + workspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +model: frontmatter-model +skills: [frontmatter-skill] +--- +# Agent + +Use frontmatter identity. +`, + }) + defer cleanupWorkspace(t, workspace) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "default-model", + }, + }, + } + + agent := NewAgentInstance(&config.AgentConfig{ + ID: "research", + Workspace: workspace, + Model: &config.AgentModelConfig{ + Primary: "config-model", + }, + Skills: []string{"config-skill"}, + }, &cfg.Agents.Defaults, cfg, &mockProvider{}) + + if agent.Model != "frontmatter-model" { + t.Fatalf("agent.Model = %q, want frontmatter-model", agent.Model) + } + if len(agent.SkillsFilter) != 1 || agent.SkillsFilter[0] != "frontmatter-skill" { + t.Fatalf("agent.SkillsFilter = %v, want [frontmatter-skill]", agent.SkillsFilter) + } +} diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index 2b577ab93..62b2ea6eb 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -211,13 +211,31 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { } func TestNewAgentLoop_AgentToolAllowlistFiltersRuntimeTools(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nMain agent.\n", + }) + defer cleanupWorkspace(t, mainWorkspace) + + researchWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +tools: [read_file, write_file, web_search, web_fetch, message] +skills: [deep-research] +--- +# Agent + +Research agent. +`, + }) + defer cleanupWorkspace(t, researchWorkspace) + cfg := testCfg([]config.AgentConfig{ - {ID: "main", Default: true}, + {ID: "main", Default: true, Workspace: mainWorkspace}, { - ID: "research", - Tools: []string{"read_file", "write_file", "web_search", "web_fetch", "message"}, + ID: "research", + Workspace: researchWorkspace, }, }) + cfg.Agents.Defaults.Workspace = mainWorkspace cfg.Tools.ReadFile.Enabled = true cfg.Tools.WriteFile.Enabled = true cfg.Tools.ListDir.Enabled = true @@ -251,13 +269,30 @@ func TestNewAgentLoop_AgentToolAllowlistFiltersRuntimeTools(t *testing.T) { } func TestNewAgentLoop_AgentToolAllowlistRequiresExactRuntimeToolNames(t *testing.T) { + mainWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nMain agent.\n", + }) + defer cleanupWorkspace(t, mainWorkspace) + + researchWorkspace := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +tools: [web] +--- +# Agent + +Research agent. +`, + }) + defer cleanupWorkspace(t, researchWorkspace) + cfg := testCfg([]config.AgentConfig{ - {ID: "main", Default: true}, + {ID: "main", Default: true, Workspace: mainWorkspace}, { - ID: "research", - Tools: []string{"web"}, + ID: "research", + Workspace: researchWorkspace, }, }) + cfg.Agents.Defaults.Workspace = mainWorkspace cfg.Tools.Web.Enabled = true cfg.Tools.Web.DuckDuckGo.Enabled = true diff --git a/pkg/agent/tool_allowlist.go b/pkg/agent/tool_allowlist.go index 41b1fb98b..899c84b89 100644 --- a/pkg/agent/tool_allowlist.go +++ b/pkg/agent/tool_allowlist.go @@ -3,17 +3,15 @@ package agent import ( "sort" "strings" - - "github.com/sipeed/picoclaw/pkg/config" ) -func resolveAgentToolAllowlist(agentCfg *config.AgentConfig) []string { - if agentCfg == nil || agentCfg.Tools == nil { +func resolveAgentToolAllowlist(definition AgentContextDefinition) []string { + if definition.Agent == nil || definition.Agent.Frontmatter.Tools == nil { return nil } - allowlist := make(map[string]struct{}, len(agentCfg.Tools)) - for _, raw := range agentCfg.Tools { + allowlist := make(map[string]struct{}, len(definition.Agent.Frontmatter.Tools)) + for _, raw := range definition.Agent.Frontmatter.Tools { trimmed := strings.ToLower(strings.TrimSpace(raw)) if trimmed == "" { continue diff --git a/pkg/config/config.go b/pkg/config/config.go index aa5953840..533f45a44 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -248,7 +248,6 @@ type AgentConfig struct { Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` - Tools []string `json:"tools,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a22bcd7cb..afb4ce425 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -120,7 +120,6 @@ func TestAgentConfig_FullParse(t *testing.T) { "primary": "claude-opus", "fallbacks": ["haiku"] }, - "tools": ["read_file", "web_search"], "subagents": { "allow_agents": ["sales"] } @@ -172,10 +171,6 @@ func TestAgentConfig_FullParse(t *testing.T) { if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" { t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks) } - if len(support.Tools) != 2 || support.Tools[0] != "read_file" || - support.Tools[1] != "web_search" { - t.Errorf("support.Tools = %v", support.Tools) - } if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 { t.Errorf("support.Subagents = %+v", support.Subagents) } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index e16be0ccb..1e6263dc8 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -51,7 +51,7 @@ func (r *ToolRegistry) SetAllowlist(names []string) { allowlist := make(map[string]struct{}, len(names)) for _, name := range names { - trimmed := strings.TrimSpace(name) + trimmed := strings.ToLower(strings.TrimSpace(name)) if trimmed == "" { continue } @@ -172,7 +172,7 @@ func (r *ToolRegistry) toolAllowedLocked(name string) bool { if r.allowlist == nil { return true } - _, ok := r.allowlist[name] + _, ok := r.allowlist[strings.ToLower(strings.TrimSpace(name))] return ok } diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 17b3cd127..2633411ff 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -101,7 +101,7 @@ func TestToolRegistry_RegisterAndGet(t *testing.T) { func TestToolRegistry_AllowlistFiltersRegistrations(t *testing.T) { r := NewToolRegistry() - r.SetAllowlist([]string{"allowed_tool"}) + r.SetAllowlist([]string{"Allowed_Tool"}) r.Register(newMockTool("allowed_tool", "allowed")) r.Register(newMockTool("blocked_tool", "blocked"))