mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(agent): move delegation details out of discovery prompt
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user