Files
picoclaw/pkg/agent/discovery.go
T

264 lines
6.4 KiB
Go

package agent
import (
"encoding/json"
"path/filepath"
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/routing"
)
// AgentDescriptor is the structured discovery payload injected into each
// agent's system prompt so the LLM can choose a peer by identity.
type AgentDescriptor struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// ListAgents returns structured descriptors for every agent in the current
// PicoClaw instance. The current workspace, when provided, is used only to
// order the matching agent first for prompt readability.
func (r *AgentRegistry) ListAgents(workspace string) []AgentDescriptor {
r.mu.RLock()
defer r.mu.RUnlock()
ids := make([]string, 0, len(r.agents))
for id := range r.agents {
ids = append(ids, id)
}
sort.Strings(ids)
selfWorkspace := cleanWorkspacePath(workspace)
descriptors := make([]AgentDescriptor, 0, len(ids))
for _, id := range ids {
agent := r.agents[id]
if agent == nil {
continue
}
descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent))
}
if selfWorkspace == "" {
return descriptors
}
sort.SliceStable(descriptors, func(i, j int) bool {
leftSelf := cleanWorkspacePath(
r.workspaceForAgentIDLocked(descriptors[i].ID),
) == selfWorkspace
rightSelf := cleanWorkspacePath(
r.workspaceForAgentIDLocked(descriptors[j].ID),
) == selfWorkspace
if leftSelf != rightSelf {
return leftSelf
}
return descriptors[i].ID < descriptors[j].ID
})
return descriptors
}
// ListSpawnableAgents returns descriptors only when the current agent can call
// spawn, and only for peers it is allowed to spawn. Restricted peers are
// intentionally omitted from discovery.
func (r *AgentRegistry) ListSpawnableAgents(agentID string) []AgentDescriptor {
r.mu.RLock()
defer r.mu.RUnlock()
parentID := routing.NormalizeAgentID(agentID)
parent, ok := r.agents[parentID]
if !ok || parent == nil {
return nil
}
if !agentHasSpawnTool(parent) {
return nil
}
ids := make([]string, 0, len(r.agents))
for id := range r.agents {
if id == parentID {
continue
}
if !agentAllowsSubagent(parent, id) {
continue
}
ids = append(ids, id)
}
sort.Strings(ids)
descriptors := make([]AgentDescriptor, 0, len(ids))
for _, id := range ids {
agent := r.agents[id]
if agent == nil {
continue
}
descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent))
}
return descriptors
}
// GetAgentDescriptor returns the structured discovery payload for one agent.
func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id := routing.NormalizeAgentID(agentID)
agent, ok := r.agents[id]
if !ok || agent == nil {
return nil, false
}
descriptor := r.buildAgentDescriptorLocked(agent)
return &descriptor, true
}
func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor {
definition := loadAgentDefinition(agent.Workspace)
name, description := descriptorIdentity(agent.ID, definition)
return AgentDescriptor{
ID: agent.ID,
Name: name,
Description: description,
}
}
func descriptorIdentity(agentID string, definition AgentContextDefinition) (string, string) {
name := agentID
description := ""
if definition.Agent != nil {
if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Name); trimmed != "" {
name = trimmed
}
if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Description); trimmed != "" {
description = trimmed
}
}
if description == "" &&
definition.Agent != nil {
if definition.Source == AgentDefinitionSourceAgent {
description = firstNonEmptyLine(definition.Agent.Body)
} else if definition.Source == AgentDefinitionSourceAgents {
description = firstMeaningfulParagraph(definition.Agent.Body)
}
}
return name, description
}
func firstNonEmptyLine(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
return trimmed
}
}
return ""
}
func firstMeaningfulParagraph(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
paragraphs := strings.Split(content, "\n\n")
for _, paragraph := range paragraphs {
lines := strings.Split(paragraph, "\n")
parts := make([]string, 0, len(lines))
inFence := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
continue
}
if inFence || trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
trimmed = strings.TrimSpace(trimmed[2:])
}
parts = append(parts, trimmed)
}
if len(parts) == 0 {
continue
}
return strings.Join(parts, " ")
}
return ""
}
func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string {
agent, ok := r.agents[routing.NormalizeAgentID(agentID)]
if !ok || agent == nil {
return ""
}
return agent.Workspace
}
func (r *AgentRegistry) defaultAgentIDLocked() string {
if _, ok := r.agents[routing.DefaultAgentID]; ok {
return routing.DefaultAgentID
}
if r.cfg != nil && len(r.cfg.Agents.List) > 0 {
for _, agentCfg := range r.cfg.Agents.List {
if !agentCfg.Default {
continue
}
id := routing.NormalizeAgentID(agentCfg.ID)
if _, ok := r.agents[id]; ok {
return id
}
}
id := routing.NormalizeAgentID(r.cfg.Agents.List[0].ID)
if _, ok := r.agents[id]; ok {
return id
}
}
for id := range r.agents {
return id
}
return ""
}
func cleanWorkspacePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Clean(path)
}
func formatAgentDiscoverySection(agents []AgentDescriptor) string {
if len(agents) == 0 {
return ""
}
payload := struct {
Agents []AgentDescriptor `json:"agents"`
}{
Agents: agents,
}
encoded, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return ""
}
var header strings.Builder
header.WriteString("# Agent Discovery\n\n")
header.WriteString("This registry lists the peer agents this agent is permitted to spawn.\n")
header.WriteString(
"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)
header.WriteString("\n```")
return header.String()
}