refactor(agent): source discovery identity from AGENT.md frontmatter

This commit is contained in:
afjcjsbx
2026-03-29 22:43:20 +02:00
parent bca131909d
commit 6429f6af9a
13 changed files with 281 additions and 160 deletions
+27 -20
View File
@@ -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"]
+27 -20
View File
@@ -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"]
+1 -1
View File
@@ -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.
+61 -79
View File
@@ -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)
+49 -15
View File
@@ -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) {
+29 -4
View File
@@ -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 {
+39
View File
@@ -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)
}
}
+41 -6
View File
@@ -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
+4 -6
View File
@@ -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
-1
View File
@@ -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"`
}
-5
View File
@@ -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)
}
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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"))