Unify agent topology and subagent memory logging

This commit is contained in:
lpf
2026-03-06 15:14:58 +08:00
parent 86691f75d0
commit cc04d9ab3a
27 changed files with 1408 additions and 791 deletions

View File

@@ -19,6 +19,9 @@ type SubagentTask struct {
Label string `json:"label"`
Role string `json:"role"`
AgentID string `json:"agent_id"`
Transport string `json:"transport,omitempty"`
NodeID string `json:"node_id,omitempty"`
ParentAgentID string `json:"parent_agent_id,omitempty"`
SessionKey string `json:"session_key"`
MemoryNS string `json:"memory_ns"`
SystemPrompt string `json:"system_prompt,omitempty"`
@@ -165,6 +168,9 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti
memoryNS := agentID
systemPrompt := ""
systemPromptFile := ""
transport := "local"
nodeID := ""
parentAgentID := ""
toolAllowlist := []string(nil)
maxRetries := 0
retryBackoff := 1000
@@ -191,6 +197,12 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti
if ns := normalizeSubagentIdentifier(profile.MemoryNamespace); ns != "" {
memoryNS = ns
}
transport = strings.TrimSpace(profile.Transport)
if transport == "" {
transport = "local"
}
nodeID = strings.TrimSpace(profile.NodeID)
parentAgentID = strings.TrimSpace(profile.ParentAgentID)
systemPrompt = strings.TrimSpace(profile.SystemPrompt)
systemPromptFile = strings.TrimSpace(profile.SystemPromptFile)
toolAllowlist = append([]string(nil), profile.ToolAllowlist...)
@@ -265,6 +277,9 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti
Label: label,
Role: role,
AgentID: agentID,
Transport: transport,
NodeID: nodeID,
ParentAgentID: parentAgentID,
SessionKey: sessionKey,
MemoryNS: memoryNS,
SystemPrompt: systemPrompt,

View File

@@ -68,6 +68,15 @@ func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[s
if v := stringArgFromMap(args, "role"); v != "" {
subcfg.Role = v
}
if v := stringArgFromMap(args, "transport"); v != "" {
subcfg.Transport = v
}
if v := stringArgFromMap(args, "node_id"); v != "" {
subcfg.NodeID = v
}
if v := stringArgFromMap(args, "parent_agent_id"); v != "" {
subcfg.ParentAgentID = v
}
if v := stringArgFromMap(args, "display_name"); v != "" {
subcfg.DisplayName = v
}
@@ -91,7 +100,10 @@ func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[s
} else if strings.TrimSpace(subcfg.Type) == "" {
subcfg.Type = "worker"
}
if subcfg.Enabled && strings.TrimSpace(subcfg.SystemPromptFile) == "" {
if strings.TrimSpace(subcfg.Transport) == "" {
subcfg.Transport = "local"
}
if subcfg.Enabled && strings.TrimSpace(subcfg.Transport) != "node" && strings.TrimSpace(subcfg.SystemPromptFile) == "" {
return nil, fmt.Errorf("system_prompt_file is required for enabled agent %q", agentID)
}
cfg.Agents.Subagents[agentID] = subcfg

View File

@@ -20,7 +20,7 @@ func NewSubagentConfigTool(configPath string) *SubagentConfigTool {
func (t *SubagentConfigTool) Name() string { return "subagent_config" }
func (t *SubagentConfigTool) Description() string {
return "Draft or persist subagent config and router rules into config.json."
return "Persist subagent config and router rules into config.json."
}
func (t *SubagentConfigTool) Parameters() map[string]interface{} {
@@ -29,17 +29,20 @@ func (t *SubagentConfigTool) Parameters() map[string]interface{} {
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"description": "draft|upsert",
"description": "upsert",
},
"description": map[string]interface{}{
"type": "string",
"description": "Natural-language worker description for draft or upsert.",
"description": "Natural-language worker description used by callers before upsert.",
},
"agent_id_hint": map[string]interface{}{
"type": "string",
"description": "Optional preferred agent id seed for draft.",
"description": "Optional preferred agent id seed used by callers before upsert.",
},
"agent_id": map[string]interface{}{"type": "string"},
"transport": map[string]interface{}{"type": "string"},
"node_id": map[string]interface{}{"type": "string"},
"parent_agent_id": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"display_name": map[string]interface{}{"type": "string"},
"system_prompt": map[string]interface{}{"type": "string"},
@@ -68,14 +71,6 @@ func (t *SubagentConfigTool) SetConfigPath(path string) {
func (t *SubagentConfigTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
_ = ctx
switch stringArgFromMap(args, "action") {
case "draft":
description := stringArgFromMap(args, "description")
if description == "" {
return "", fmt.Errorf("description is required")
}
return marshalSubagentConfigPayload(map[string]interface{}{
"draft": DraftConfigSubagent(description, stringArgFromMap(args, "agent_id_hint")),
})
case "upsert":
result, err := UpsertConfigSubagent(t.getConfigPath(), cloneSubagentConfigArgs(args))
if err != nil {

View File

@@ -10,28 +10,6 @@ import (
"clawgo/pkg/runtimecfg"
)
func TestSubagentConfigToolDraft(t *testing.T) {
tool := NewSubagentConfigTool("")
out, err := tool.Execute(context.Background(), map[string]interface{}{
"action": "draft",
"description": "创建一个负责回归测试和验证修复结果的子代理",
})
if err != nil {
t.Fatalf("draft failed: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("unmarshal payload failed: %v", err)
}
draft, ok := payload["draft"].(map[string]interface{})
if !ok {
t.Fatalf("expected draft map, got %#v", payload["draft"])
}
if draft["agent_id"] == "" || draft["role"] == "" {
t.Fatalf("expected draft agent_id and role, got %#v", draft)
}
}
func TestSubagentConfigToolUpsert(t *testing.T) {
workspace := t.TempDir()
configPath := filepath.Join(workspace, "config.json")

View File

@@ -12,12 +12,16 @@ import (
"time"
"clawgo/pkg/config"
"clawgo/pkg/nodes"
"clawgo/pkg/runtimecfg"
)
type SubagentProfile struct {
AgentID string `json:"agent_id"`
Name string `json:"name"`
Transport string `json:"transport,omitempty"`
NodeID string `json:"node_id,omitempty"`
ParentAgentID string `json:"parent_agent_id,omitempty"`
Role string `json:"role,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptFile string `json:"system_prompt_file,omitempty"`
@@ -122,6 +126,9 @@ func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile
if managed, ok := s.configProfileLocked(p.AgentID); ok {
return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy)
}
if managed, ok := s.nodeProfileLocked(p.AgentID); ok {
return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy)
}
now := time.Now().UnixMilli()
path := s.profilePath(p.AgentID)
@@ -160,6 +167,9 @@ func (s *SubagentProfileStore) Delete(agentID string) error {
if managed, ok := s.configProfileLocked(id); ok {
return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy)
}
if managed, ok := s.nodeProfileLocked(id); ok {
return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy)
}
err := os.Remove(s.profilePath(id))
if err != nil && !os.IsNotExist(err) {
@@ -175,6 +185,9 @@ func normalizeSubagentProfile(in SubagentProfile) SubagentProfile {
if p.Name == "" {
p.Name = p.AgentID
}
p.Transport = normalizeProfileTransport(p.Transport)
p.NodeID = strings.TrimSpace(p.NodeID)
p.ParentAgentID = normalizeSubagentIdentifier(p.ParentAgentID)
p.Role = strings.TrimSpace(p.Role)
p.SystemPrompt = strings.TrimSpace(p.SystemPrompt)
p.SystemPromptFile = strings.TrimSpace(p.SystemPromptFile)
@@ -203,6 +216,17 @@ func normalizeProfileStatus(s string) string {
}
}
func normalizeProfileTransport(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "local":
return "local"
case "node":
return "node"
default:
return "local"
}
}
func normalizeStringList(in []string) []string {
if len(in) == 0 {
return nil
@@ -266,6 +290,12 @@ func (s *SubagentProfileStore) mergedProfilesLocked() (map[string]SubagentProfil
for _, p := range s.configProfilesLocked() {
merged[p.AgentID] = p
}
for _, p := range s.nodeProfilesLocked() {
if _, exists := merged[p.AgentID]; exists {
continue
}
merged[p.AgentID] = p
}
fileProfiles, err := s.fileProfilesLocked()
if err != nil {
return nil, err
@@ -339,6 +369,27 @@ func (s *SubagentProfileStore) configProfileLocked(agentID string) (SubagentProf
return profileFromConfig(id, subcfg), true
}
func (s *SubagentProfileStore) nodeProfileLocked(agentID string) (SubagentProfile, bool) {
id := normalizeSubagentIdentifier(agentID)
if id == "" {
return SubagentProfile{}, false
}
cfg := runtimecfg.Get()
parentAgentID := "main"
if cfg != nil {
if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" {
parentAgentID = mainID
}
}
for _, node := range nodes.DefaultManager().List() {
profile := profileFromNode(node, parentAgentID)
if profile.AgentID == id {
return profile, true
}
}
return SubagentProfile{}, false
}
func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentProfile {
status := "active"
if !subcfg.Enabled {
@@ -347,6 +398,9 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro
return normalizeSubagentProfile(SubagentProfile{
AgentID: agentID,
Name: strings.TrimSpace(subcfg.DisplayName),
Transport: strings.TrimSpace(subcfg.Transport),
NodeID: strings.TrimSpace(subcfg.NodeID),
ParentAgentID: strings.TrimSpace(subcfg.ParentAgentID),
Role: strings.TrimSpace(subcfg.Role),
SystemPrompt: strings.TrimSpace(subcfg.SystemPrompt),
SystemPromptFile: strings.TrimSpace(subcfg.SystemPromptFile),
@@ -362,6 +416,63 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro
})
}
func (s *SubagentProfileStore) nodeProfilesLocked() []SubagentProfile {
nodeItems := nodes.DefaultManager().List()
if len(nodeItems) == 0 {
return nil
}
cfg := runtimecfg.Get()
parentAgentID := "main"
if cfg != nil {
if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" {
parentAgentID = mainID
}
}
out := make([]SubagentProfile, 0, len(nodeItems))
for _, node := range nodeItems {
profile := profileFromNode(node, parentAgentID)
if profile.AgentID == "" {
continue
}
out = append(out, profile)
}
return out
}
func profileFromNode(node nodes.NodeInfo, parentAgentID string) SubagentProfile {
agentID := nodeBranchAgentID(node.ID)
if agentID == "" {
return SubagentProfile{}
}
name := strings.TrimSpace(node.Name)
if name == "" {
name = strings.TrimSpace(node.ID)
}
status := "active"
if !node.Online {
status = "disabled"
}
return normalizeSubagentProfile(SubagentProfile{
AgentID: agentID,
Name: name + " Main Agent",
Transport: "node",
NodeID: strings.TrimSpace(node.ID),
ParentAgentID: parentAgentID,
Role: "remote_main",
MemoryNamespace: agentID,
Status: status,
ManagedBy: "node_registry",
})
}
func nodeBranchAgentID(nodeID string) string {
id := normalizeSubagentIdentifier(nodeID)
if id == "" {
return ""
}
return "node." + id + ".main"
}
type SubagentProfileTool struct {
store *SubagentProfileStore
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"clawgo/pkg/config"
"clawgo/pkg/nodes"
"clawgo/pkg/runtimecfg"
)
@@ -185,3 +186,51 @@ func TestSubagentProfileStoreRejectsWritesForConfigManagedProfiles(t *testing.T)
t.Fatalf("expected config-managed delete to fail")
}
}
func TestSubagentProfileStoreIncludesNodeMainBranchProfiles(t *testing.T) {
runtimecfg.Set(config.DefaultConfig())
t.Cleanup(func() {
runtimecfg.Set(config.DefaultConfig())
nodes.DefaultManager().Remove("edge-dev")
})
cfg := config.DefaultConfig()
cfg.Agents.Router.Enabled = true
cfg.Agents.Router.MainAgentID = "main"
cfg.Agents.Subagents["main"] = config.SubagentConfig{
Enabled: true,
Type: "router",
SystemPromptFile: "agents/main/AGENT.md",
}
runtimecfg.Set(cfg)
nodes.DefaultManager().Upsert(nodes.NodeInfo{
ID: "edge-dev",
Name: "Edge Dev",
Online: true,
Capabilities: nodes.Capabilities{
Model: true,
},
})
store := NewSubagentProfileStore(t.TempDir())
profile, ok, err := store.Get(nodeBranchAgentID("edge-dev"))
if err != nil {
t.Fatalf("get failed: %v", err)
}
if !ok {
t.Fatalf("expected node-backed profile")
}
if profile.ManagedBy != "node_registry" || profile.Transport != "node" || profile.NodeID != "edge-dev" {
t.Fatalf("unexpected node profile: %+v", profile)
}
if profile.ParentAgentID != "main" {
t.Fatalf("expected main parent agent, got %+v", profile)
}
if _, err := store.Upsert(SubagentProfile{AgentID: profile.AgentID}); err == nil {
t.Fatalf("expected node-managed upsert to fail")
}
if err := store.Delete(profile.AgentID); err == nil {
t.Fatalf("expected node-managed delete to fail")
}
}