refactor(agent): move delegation details out of discovery prompt

This commit is contained in:
afjcjsbx
2026-03-29 22:57:57 +02:00
parent 6429f6af9a
commit 0ef25f779e
6 changed files with 48 additions and 278 deletions
+6 -28
View File
@@ -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
+6 -28
View File
@@ -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
+1 -7
View File
@@ -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
+25 -138
View File
@@ -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)
+8 -73
View File
@@ -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)
}
}
+2 -4
View File
@@ -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)
}
}