mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(agent): source discovery identity from AGENT.md frontmatter
This commit is contained in:
+27
-20
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user