diff --git a/docs/configuration.md b/docs/configuration.md index ab18bcaf5..e0ad00367 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -269,7 +269,7 @@ Notes: - This is an allowlist, not a preference hint. - Tool names are matched against the runtime tool name 1:1. - Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- `available_tools` in Agent Discovery reflects the filtered runtime result, while `tools` reflects the identity declared in `AGENT.md`. +- Tool declarations in `AGENT.md` are used by runtime/tooling, but they are not injected into the discovery prompt. ### Agent Discovery (Automatic) @@ -284,56 +284,34 @@ Each entry includes: | `id` | Stable agent id | | `name` | Agent identity name from `AGENT.md` frontmatter | | `description` | Agent identity description from `AGENT.md` frontmatter | -| `tools` | Declared tool identity from `AGENT.md` frontmatter | -| `skills` | Declared skill identity from `AGENT.md` frontmatter | -| `mcpServers` | Declared MCP server identity from `AGENT.md` frontmatter | -| `model` | Declared model from `AGENT.md` frontmatter | -| `available_tools` | Tool names currently visible to that agent | -| `channels` | Channels that route to that agent | Important behavior: - The discovery section includes the current agent's own entry, so the model has self-awareness. -- `available_tools` is the most important field for delegation. It reflects the tools the target agent can actually use, not just a natural-language description. -- Identity fields (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) come from `AGENT.md` frontmatter. +- Discovery is intentionally lightweight. It gives the model only the identity it needs to choose a peer: `id`, `name`, and `description`. - `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. -- `channels` come from routing state: - - the default agent exposes enabled channels - - other agents expose channels that explicitly bind to them through `bindings` +- `AGENT.md` remains the identity layer. Runtime/tool code can still use its `tools`, `skills`, `mcpServers`, and `model` fields when delegation happens. Example injected shape: ```json { - "current_agent_id": "main", "agents": [ { "id": "main", "name": "Main Assistant", - "description": "Generalist agent for day-to-day requests.", - "tools": ["read_file", "write_file", "exec", "spawn"], - "skills": ["coordination"], - "mcpServers": ["filesystem"], - "model": "gpt-4o-mini", - "available_tools": ["read_file", "write_file", "exec", "spawn"], - "channels": ["telegram", "discord"] + "description": "Generalist agent for day-to-day requests." }, { "id": "research", "name": "Research Agent", - "description": "Specialist for long-form investigation and web work.", - "tools": ["read_file", "web_search", "web_fetch", "message"], - "skills": ["deep-research"], - "mcpServers": ["web-index"], - "model": "claude-sonnet-4.5", - "available_tools": ["web_search", "web_fetch", "read_file"], - "channels": ["telegram"] + "description": "Specialist for long-form investigation and web work." } ] } ``` -In practice, this means a generalist agent can see that a peer has `["web_search", "web_fetch"]` while it only has local file tools, and can decide to delegate to that peer instead of guessing. +In practice, this means a generalist agent can choose a peer based on its role description, then call `spawn` with the peer's `agent_id`. The runtime resolves the rest. ### 🔒 Security Sandbox diff --git a/docs/it/configuration.md b/docs/it/configuration.md index ef77f55ab..4b0153e2c 100644 --- a/docs/it/configuration.md +++ b/docs/it/configuration.md @@ -94,7 +94,7 @@ Note: - È una allowlist reale, non un suggerimento per l'LLM. - I nomi dei tool fanno match 1:1 con il nome runtime del tool. - Se ti serve controllo preciso, usa i nomi runtime effettivi come `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`. -- `available_tools` nella Agent Discovery riflette il risultato runtime filtrato, mentre `tools` riflette l'identità dichiarata in `AGENT.md`. +- Le dichiarazioni dei tool in `AGENT.md` sono usate dal runtime e dai tool, ma non vengono iniettate nel prompt di discovery. ### Discovery Multi-Agent (Automatica) @@ -109,56 +109,34 @@ Ogni entry include: | `id` | ID stabile dell'agent | | `name` | Nome identitario da `AGENT.md` frontmatter | | `description` | Descrizione identitaria da `AGENT.md` frontmatter | -| `tools` | Tool dichiarati nel frontmatter di `AGENT.md` | -| `skills` | Skill dichiarate nel frontmatter di `AGENT.md` | -| `mcpServers` | Server MCP dichiarati nel frontmatter di `AGENT.md` | -| `model` | Modello dichiarato nel frontmatter di `AGENT.md` | -| `available_tools` | Tool attualmente visibili a quell'agent | -| `channels` | Canali instradati verso quell'agent | Dettagli importanti: - La sezione include anche l'entry dell'agent corrente, quindi c'è self-awareness. -- `available_tools` è il campo più importante per delegare bene: l'LLM vede i tool reali del peer, non deve indovinarli dalla sola descrizione. -- I campi di identità (`name`, `description`, `tools`, `skills`, `mcpServers`, `model`) arrivano dal frontmatter di `AGENT.md`. +- La discovery è volutamente leggera. Fornisce al modello solo l'identità necessaria per scegliere un peer: `id`, `name`, `description`. - `config.json` resta il layer infrastrutturale: workspace, agent di default, routing e permessi di subagent. -- `channels` derivano dal routing: - - l'agent di default espone i canali abilitati - - gli altri agent espongono i canali che hanno un binding esplicito verso di loro +- `AGENT.md` resta il layer di identità. Il codice runtime e i tool possono comunque usare `tools`, `skills`, `mcpServers` e `model` quando avviene la delega. Forma dell'oggetto iniettato: ```json { - "current_agent_id": "main", "agents": [ { "id": "main", "name": "Main Assistant", - "description": "Agent generalista per richieste quotidiane.", - "tools": ["read_file", "write_file", "exec", "spawn"], - "skills": ["coordination"], - "mcpServers": ["filesystem"], - "model": "gpt-4o-mini", - "available_tools": ["read_file", "write_file", "exec", "spawn"], - "channels": ["telegram", "discord"] + "description": "Agent generalista per richieste quotidiane." }, { "id": "research", "name": "Research Agent", - "description": "Specialista per investigazioni e lavoro web.", - "tools": ["read_file", "web_search", "web_fetch", "message"], - "skills": ["deep-research"], - "mcpServers": ["web-index"], - "model": "claude-sonnet-4.5", - "available_tools": ["web_search", "web_fetch", "read_file"], - "channels": ["telegram"] + "description": "Specialista per investigazioni e lavoro web." } ] } ``` -In pratica, un agent generalista può vedere che un peer ha `["web_search", "web_fetch"]` mentre lui ha solo tool locali, e scegliere di delegare a quel peer in modo esplicito invece di andare a tentativi. +In pratica, un agent generalista sceglie un peer in base alla descrizione del suo ruolo, poi chiama `spawn` con l'`agent_id` del peer. Il runtime risolve il resto. ### 🔒 Sandbox di Sicurezza diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 31b60e45a..1ff25f296 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -22,7 +22,6 @@ import ( type ContextBuilder struct { workspace string - agentID string skillsLoader *skills.SkillsLoader memory *MemoryStore toolDiscoveryBM25 bool @@ -60,11 +59,6 @@ func (cb *ContextBuilder) WithSplitOnMarker(enabled bool) *ContextBuilder { return cb } -func (cb *ContextBuilder) WithAgentIdentity(agentID string) *ContextBuilder { - cb.agentID = strings.TrimSpace(agentID) - return cb -} - func (cb *ContextBuilder) WithAgentDiscovery( discover func(workspace string) []AgentDescriptor, ) *ContextBuilder { @@ -200,7 +194,7 @@ func (cb *ContextBuilder) buildAgentDiscoveryContext() string { if cb.agentDiscovery == nil { return "" } - return formatAgentDiscoverySection(cb.agentID, cb.agentDiscovery(cb.workspace)) + return formatAgentDiscoverySection(cb.agentDiscovery(cb.workspace)) } // BuildSystemPromptWithCache returns the cached system prompt if available diff --git a/pkg/agent/discovery.go b/pkg/agent/discovery.go index 31b05c635..6cd49f2a6 100644 --- a/pkg/agent/discovery.go +++ b/pkg/agent/discovery.go @@ -2,23 +2,19 @@ package agent import ( "encoding/json" - "fmt" "path/filepath" - "reflect" "sort" "strings" - "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/routing" ) // AgentDescriptor is the structured discovery payload injected into each -// agent's system prompt so the LLM can make concrete delegation decisions. +// agent's system prompt so the LLM can choose a peer by identity. type AgentDescriptor struct { - ID string `json:"id"` - AgentFrontmatter - AvailableTools []string `json:"available_tools"` - Channels []string `json:"channels"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` } // ListAgents returns structured descriptors for every agent in the current @@ -81,54 +77,34 @@ func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bo func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor { definition := loadAgentDefinition(agent.Workspace) + name, description := descriptorIdentity(agent.ID, definition) return AgentDescriptor{ - ID: agent.ID, - AgentFrontmatter: descriptorFrontmatter(agent.ID, definition), - AvailableTools: visibleToolNames(agent), - Channels: r.channelsForAgentLocked(agent.ID), + ID: agent.ID, + Name: name, + Description: description, } } -func visibleToolNames(agent *AgentInstance) []string { - if agent == nil || agent.Tools == nil { - return []string{} - } - - defs := agent.Tools.ToProviderDefs() - names := make([]string, 0, len(defs)) - for _, def := range defs { - name := strings.TrimSpace(def.Function.Name) - if name == "" { - continue - } - names = append(names, name) - } - if names == nil { - return []string{} - } - return names -} - -func descriptorFrontmatter(agentID string, definition AgentContextDefinition) AgentFrontmatter { - frontmatter := AgentFrontmatter{} +func descriptorIdentity(agentID string, definition AgentContextDefinition) (string, string) { + name := agentID + description := "" if definition.Agent != nil { - frontmatter = definition.Agent.Frontmatter - frontmatter.Tools = append([]string(nil), frontmatter.Tools...) - frontmatter.Skills = append([]string(nil), frontmatter.Skills...) - frontmatter.MCPServers = append([]string(nil), frontmatter.MCPServers...) + if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Name); trimmed != "" { + name = trimmed + } + if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Description); trimmed != "" { + description = trimmed + } } - if strings.TrimSpace(frontmatter.Name) == "" { - frontmatter.Name = agentID - } - if strings.TrimSpace(frontmatter.Description) == "" && + if description == "" && definition.Source == AgentDefinitionSourceAgents && definition.Agent != nil { - frontmatter.Description = firstMeaningfulParagraph(definition.Agent.Body) + description = firstMeaningfulParagraph(definition.Agent.Body) } - return frontmatter + return name, description } func firstMeaningfulParagraph(content string) string { @@ -163,85 +139,6 @@ func firstMeaningfulParagraph(content string) string { return "" } -func (r *AgentRegistry) channelsForAgentLocked(agentID string) []string { - channels := make(map[string]struct{}) - enabled := enabledChannelSet(r.cfg) - - if defaultID := r.defaultAgentIDLocked(); defaultID != "" && defaultID == agentID { - for channel := range enabled { - channels[channel] = struct{}{} - } - } - - if r.cfg != nil { - for _, binding := range r.cfg.Bindings { - if routing.NormalizeAgentID(binding.AgentID) != agentID { - continue - } - channel := strings.ToLower(strings.TrimSpace(binding.Match.Channel)) - if channel == "" { - continue - } - if _, ok := enabled[channel]; !ok { - continue - } - channels[channel] = struct{}{} - } - } - - if len(channels) == 0 { - return []string{} - } - - result := make([]string, 0, len(channels)) - for channel := range channels { - result = append(result, channel) - } - sort.Strings(result) - return result -} - -func enabledChannels(cfg *config.Config) []string { - if cfg == nil { - return []string{} - } - - value := reflect.ValueOf(cfg.Channels) - typ := value.Type() - enabled := make([]string, 0, typ.NumField()) - for i := 0; i < typ.NumField(); i++ { - fieldValue := value.Field(i) - enabledField := fieldValue.FieldByName("Enabled") - if !enabledField.IsValid() || enabledField.Kind() != reflect.Bool || !enabledField.Bool() { - continue - } - name := jsonFieldName(typ.Field(i).Tag.Get("json")) - if name == "" { - continue - } - enabled = append(enabled, name) - } - sort.Strings(enabled) - return enabled -} - -func enabledChannelSet(cfg *config.Config) map[string]struct{} { - channels := enabledChannels(cfg) - result := make(map[string]struct{}, len(channels)) - for _, channel := range channels { - result[channel] = struct{}{} - } - return result -} - -func jsonFieldName(tag string) string { - name := strings.TrimSpace(strings.Split(tag, ",")[0]) - if name == "" || name == "-" { - return "" - } - return name -} - func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string { agent, ok := r.agents[routing.NormalizeAgentID(agentID)] if !ok || agent == nil { @@ -283,17 +180,15 @@ func cleanWorkspacePath(path string) string { return filepath.Clean(path) } -func formatAgentDiscoverySection(currentAgentID string, agents []AgentDescriptor) string { +func formatAgentDiscoverySection(agents []AgentDescriptor) string { if len(agents) <= 1 { return "" } payload := struct { - CurrentAgentID string `json:"current_agent_id"` - Agents []AgentDescriptor `json:"agents"` + Agents []AgentDescriptor `json:"agents"` }{ - CurrentAgentID: strings.TrimSpace(currentAgentID), - Agents: agents, + Agents: agents, } encoded, err := json.MarshalIndent(payload, "", " ") @@ -303,17 +198,9 @@ func formatAgentDiscoverySection(currentAgentID string, agents []AgentDescriptor var header strings.Builder header.WriteString("# Agent Discovery\n\n") - if payload.CurrentAgentID != "" { - fmt.Fprintf( - &header, - "You are agent %q. This registry is authoritative for the current PicoClaw instance and includes your own entry.\n", - payload.CurrentAgentID, - ) - } else { - header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") - } + header.WriteString("This registry is authoritative for the current PicoClaw instance.\n") header.WriteString( - "Delegate based on available_tools first, then skills, mcpServers, model, channels, and description. Use only agent IDs listed here.\n\n", + "Choose a peer based on its description. Use only agent IDs listed here when calling spawn.\n\n", ) header.WriteString("```json\n") header.Write(encoded) diff --git a/pkg/agent/discovery_test.go b/pkg/agent/discovery_test.go index 83a5472e3..4dbaea900 100644 --- a/pkg/agent/discovery_test.go +++ b/pkg/agent/discovery_test.go @@ -1,7 +1,6 @@ package agent import ( - "slices" "strings" "testing" @@ -13,10 +12,6 @@ func TestAgentRegistry_ListAgentsBuildsStructuredDescriptors(t *testing.T) { "AGENT.md": `--- name: Main Frontmatter Name description: Structured main agent -model: main-frontmatter-model -tools: [read_file, write_file] -skills: [coordination] -mcpServers: [filesystem] --- # Agent @@ -29,10 +24,6 @@ Handle general requests. "AGENT.md": `--- name: Support Frontmatter Name description: Support frontmatter description -model: support-frontmatter-model -tools: [read_file] -skills: [support-playbook] -mcpServers: [support-db] --- # Agent @@ -45,18 +36,6 @@ Handle support tickets carefully. {ID: "main", Default: true, Name: "Configured Main", Workspace: mainWorkspace}, {ID: "support", Workspace: supportWorkspace}, }) - cfg.Tools.ReadFile.Enabled = true - cfg.Tools.WriteFile.Enabled = true - cfg.Channels.Telegram.Enabled = true - cfg.Bindings = []config.AgentBinding{ - { - AgentID: "support", - Match: config.BindingMatch{ - Channel: "telegram", - AccountID: "*", - }, - }, - } registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) @@ -74,28 +53,6 @@ Handle support tickets carefully. if descriptors[0].Description != "Structured main agent" { t.Fatalf("expected frontmatter description, got %q", descriptors[0].Description) } - if descriptors[0].Model != "main-frontmatter-model" { - t.Fatalf("expected frontmatter model, got %q", descriptors[0].Model) - } - if !slices.Equal(descriptors[0].Tools, []string{"read_file", "write_file"}) { - t.Fatalf("expected declared frontmatter tools, got %v", descriptors[0].Tools) - } - if !slices.Equal(descriptors[0].Skills, []string{"coordination"}) { - t.Fatalf("expected frontmatter skills, got %v", descriptors[0].Skills) - } - if !slices.Equal(descriptors[0].MCPServers, []string{"filesystem"}) { - t.Fatalf("expected frontmatter mcpServers, got %v", descriptors[0].MCPServers) - } - if !slices.Contains(descriptors[0].AvailableTools, "read_file") || - !slices.Contains(descriptors[0].AvailableTools, "write_file") { - t.Fatalf("expected visible file tools in descriptor, got %v", descriptors[0].AvailableTools) - } - if !slices.Equal(descriptors[0].Channels, []string{"telegram"}) { - t.Fatalf( - "expected default agent to cover enabled telegram channel, got %v", - descriptors[0].Channels, - ) - } support, ok := registry.GetAgentDescriptor("support") if !ok || support == nil { @@ -107,25 +64,12 @@ Handle support tickets carefully. if support.Description != "Support frontmatter description" { t.Fatalf("expected support frontmatter description, got %q", support.Description) } - if support.Model != "support-frontmatter-model" { - t.Fatalf("expected support frontmatter model, got %q", support.Model) - } - if !slices.Equal(support.Skills, []string{"support-playbook"}) { - t.Fatalf("expected support skills, got %v", support.Skills) - } - if !slices.Equal(support.MCPServers, []string{"support-db"}) { - t.Fatalf("expected support mcpServers, got %v", support.MCPServers) - } - if !slices.Equal(support.Channels, []string{"telegram"}) { - t.Fatalf("expected support channel binding, got %v", support.Channels) - } } func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) { mainWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- description: Main agent -skills: [coordination] --- # Agent @@ -136,9 +80,8 @@ Generalist. researchWorkspace := setupWorkspace(t, map[string]string{ "AGENT.md": `--- +name: Research Agent description: Research specialist -skills: [deep-research] -mcpServers: [web-index] --- # Agent @@ -178,23 +121,18 @@ Investigate deeply. if !strings.Contains(systemPrompt, "# Agent Discovery") { t.Fatalf("expected discovery section in system prompt, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"current_agent_id": "main"`) { - t.Fatalf("expected current agent id in discovery section, got %q", systemPrompt) - } if !strings.Contains(systemPrompt, `"id": "main"`) || !strings.Contains(systemPrompt, `"id": "research"`) { t.Fatalf("expected self and peer descriptors in discovery section, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"available_tools": [`) || - !strings.Contains(systemPrompt, `"read_file"`) || - !strings.Contains(systemPrompt, `"write_file"`) { - t.Fatalf("expected visible tool list in discovery section, got %q", systemPrompt) + if !strings.Contains(systemPrompt, `"name": "main"`) || + !strings.Contains(systemPrompt, `"description": "Research specialist"`) { + t.Fatalf("expected minimal identity fields in discovery section, got %q", systemPrompt) } - if !strings.Contains(systemPrompt, `"skills": [`) || !strings.Contains(systemPrompt, `"deep-research"`) { - t.Fatalf("expected frontmatter skills in discovery section, got %q", systemPrompt) - } - if !strings.Contains(systemPrompt, `"mcpServers": [`) || !strings.Contains(systemPrompt, `"web-index"`) { - t.Fatalf("expected frontmatter mcpServers in discovery section, got %q", systemPrompt) + for _, forbidden := range []string{`"current_agent_id"`, `"available_tools"`, `"model"`, `"channels"`, `"skills"`, `"mcpServers"`, `"tools"`} { + if strings.Contains(systemPrompt, forbidden) { + t.Fatalf("did not expect %s in discovery section, got %q", forbidden, systemPrompt) + } } } @@ -239,7 +177,4 @@ Generalist. if strings.Contains(systemPrompt, "# Agent Discovery") { t.Fatalf("did not expect discovery section for singleton registry, got %q", systemPrompt) } - if strings.Contains(systemPrompt, `"current_agent_id": "main"`) { - t.Fatalf("did not expect discovery payload for singleton registry, got %q", systemPrompt) - } } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 46f54f5c8..ef5645e51 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -54,11 +54,9 @@ func NewAgentRegistry( } } - for id, instance := range registry.agents { + for _, instance := range registry.agents { if instance.ContextBuilder != nil { - instance.ContextBuilder. - WithAgentIdentity(id). - WithAgentDiscovery(registry.ListAgents) + instance.ContextBuilder.WithAgentDiscovery(registry.ListAgents) } }