Refine agent config schema and prompt file loading

This commit is contained in:
lpf
2026-03-06 13:56:38 +08:00
parent 1fec90643b
commit 2bc71870db
16 changed files with 483 additions and 250 deletions

View File

@@ -14,36 +14,37 @@ import (
)
type SubagentTask struct {
ID string `json:"id"`
Task string `json:"task"`
Label string `json:"label"`
Role string `json:"role"`
AgentID string `json:"agent_id"`
SessionKey string `json:"session_key"`
MemoryNS string `json:"memory_ns"`
SystemPrompt string `json:"system_prompt,omitempty"`
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
RetryBackoff int `json:"retry_backoff,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"`
MaxTaskChars int `json:"max_task_chars,omitempty"`
MaxResultChars int `json:"max_result_chars,omitempty"`
RetryCount int `json:"retry_count,omitempty"`
PipelineID string `json:"pipeline_id,omitempty"`
PipelineTask string `json:"pipeline_task,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentRunID string `json:"parent_run_id,omitempty"`
LastMessageID string `json:"last_message_id,omitempty"`
WaitingReply bool `json:"waiting_for_reply,omitempty"`
SharedState map[string]interface{} `json:"shared_state,omitempty"`
OriginChannel string `json:"origin_channel,omitempty"`
OriginChatID string `json:"origin_chat_id,omitempty"`
Status string `json:"status"`
Result string `json:"result,omitempty"`
Steering []string `json:"steering,omitempty"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
ID string `json:"id"`
Task string `json:"task"`
Label string `json:"label"`
Role string `json:"role"`
AgentID string `json:"agent_id"`
SessionKey string `json:"session_key"`
MemoryNS string `json:"memory_ns"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptFile string `json:"system_prompt_file,omitempty"`
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
RetryBackoff int `json:"retry_backoff,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"`
MaxTaskChars int `json:"max_task_chars,omitempty"`
MaxResultChars int `json:"max_result_chars,omitempty"`
RetryCount int `json:"retry_count,omitempty"`
PipelineID string `json:"pipeline_id,omitempty"`
PipelineTask string `json:"pipeline_task,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentRunID string `json:"parent_run_id,omitempty"`
LastMessageID string `json:"last_message_id,omitempty"`
WaitingReply bool `json:"waiting_for_reply,omitempty"`
SharedState map[string]interface{} `json:"shared_state,omitempty"`
OriginChannel string `json:"origin_channel,omitempty"`
OriginChatID string `json:"origin_chat_id,omitempty"`
Status string `json:"status"`
Result string `json:"result,omitempty"`
Steering []string `json:"steering,omitempty"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
}
type SubagentManager struct {
@@ -163,6 +164,7 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti
}
memoryNS := agentID
systemPrompt := ""
systemPromptFile := ""
toolAllowlist := []string(nil)
maxRetries := 0
retryBackoff := 1000
@@ -190,6 +192,7 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti
memoryNS = ns
}
systemPrompt = strings.TrimSpace(profile.SystemPrompt)
systemPromptFile = strings.TrimSpace(profile.SystemPromptFile)
toolAllowlist = append([]string(nil), profile.ToolAllowlist...)
maxRetries = profile.MaxRetries
retryBackoff = profile.RetryBackoff
@@ -257,31 +260,32 @@ func (sm *SubagentManager) spawnTask(ctx context.Context, opts SubagentSpawnOpti
}
}
subagentTask := &SubagentTask{
ID: taskID,
Task: task,
Label: label,
Role: role,
AgentID: agentID,
SessionKey: sessionKey,
MemoryNS: memoryNS,
SystemPrompt: systemPrompt,
ToolAllowlist: toolAllowlist,
MaxRetries: maxRetries,
RetryBackoff: retryBackoff,
TimeoutSec: timeoutSec,
MaxTaskChars: maxTaskChars,
MaxResultChars: maxResultChars,
RetryCount: 0,
PipelineID: pipelineID,
PipelineTask: pipelineTask,
ThreadID: threadID,
CorrelationID: correlationID,
ParentRunID: parentRunID,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
Created: now,
Updated: now,
ID: taskID,
Task: task,
Label: label,
Role: role,
AgentID: agentID,
SessionKey: sessionKey,
MemoryNS: memoryNS,
SystemPrompt: systemPrompt,
SystemPromptFile: systemPromptFile,
ToolAllowlist: toolAllowlist,
MaxRetries: maxRetries,
RetryBackoff: retryBackoff,
TimeoutSec: timeoutSec,
MaxTaskChars: maxTaskChars,
MaxResultChars: maxResultChars,
RetryCount: 0,
PipelineID: pipelineID,
PipelineTask: pipelineTask,
ThreadID: threadID,
CorrelationID: correlationID,
ParentRunID: parentRunID,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
Created: now,
Updated: now,
}
taskCtx, cancel := context.WithCancel(ctx)
sm.tasks[taskID] = subagentTask
@@ -469,19 +473,7 @@ func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTa
return "", fmt.Errorf("no llm provider configured for subagent execution")
}
systemPrompt := "You are a subagent. Follow workspace AGENTS.md and complete the task independently."
rolePrompt := strings.TrimSpace(task.SystemPrompt)
if ws := strings.TrimSpace(sm.workspace); ws != "" {
if data, err := os.ReadFile(filepath.Join(ws, "AGENTS.md")); err == nil {
txt := strings.TrimSpace(string(data))
if txt != "" {
systemPrompt = "Workspace policy (AGENTS.md):\n" + txt + "\n\nComplete the given task independently and report the result."
}
}
}
if rolePrompt != "" {
systemPrompt += "\n\nRole-specific profile prompt:\n" + rolePrompt
}
systemPrompt := sm.resolveSystemPrompt(task)
messages := []providers.Message{
{
Role: "system",
@@ -510,6 +502,44 @@ func (sm *SubagentManager) executeTaskOnce(ctx context.Context, task *SubagentTa
return response.Content, nil
}
func (sm *SubagentManager) resolveSystemPrompt(task *SubagentTask) string {
systemPrompt := "You are a subagent. Follow workspace AGENTS.md and complete the task independently."
workspacePrompt := sm.readWorkspacePromptFile("AGENTS.md")
if workspacePrompt != "" {
systemPrompt = "Workspace policy (AGENTS.md):\n" + workspacePrompt + "\n\nComplete the given task independently and report the result."
}
if task == nil {
return systemPrompt
}
if promptFile := strings.TrimSpace(task.SystemPromptFile); promptFile != "" {
if promptText := sm.readWorkspacePromptFile(promptFile); promptText != "" {
return systemPrompt + "\n\nSubagent policy (" + promptFile + "):\n" + promptText
}
}
if rolePrompt := strings.TrimSpace(task.SystemPrompt); rolePrompt != "" {
return systemPrompt + "\n\nRole-specific profile prompt:\n" + rolePrompt
}
return systemPrompt
}
func (sm *SubagentManager) readWorkspacePromptFile(relPath string) string {
ws := strings.TrimSpace(sm.workspace)
relPath = strings.TrimSpace(relPath)
if ws == "" || relPath == "" || filepath.IsAbs(relPath) {
return ""
}
fullPath := filepath.Clean(filepath.Join(ws, relPath))
relToWorkspace, err := filepath.Rel(ws, fullPath)
if err != nil || strings.HasPrefix(relToWorkspace, "..") {
return ""
}
data, err := os.ReadFile(fullPath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
type SubagentRunFunc func(ctx context.Context, task *SubagentTask) (string, error)
func (sm *SubagentManager) SetRunFunc(f SubagentRunFunc) {

View File

@@ -23,15 +23,16 @@ func DraftConfigSubagent(description, agentIDHint string) map[string]interface{}
keywords := inferDraftKeywords(role, lower)
systemPrompt := inferDraftSystemPrompt(role, desc)
return map[string]interface{}{
"agent_id": agentID,
"role": role,
"display_name": displayName,
"description": desc,
"system_prompt": systemPrompt,
"memory_namespace": agentID,
"tool_allowlist": toolAllowlist,
"routing_keywords": keywords,
"type": "worker",
"agent_id": agentID,
"role": role,
"display_name": displayName,
"description": desc,
"system_prompt": systemPrompt,
"system_prompt_file": "agents/" + agentID + "/AGENT.md",
"memory_namespace": agentID,
"tool_allowlist": toolAllowlist,
"routing_keywords": keywords,
"type": "worker",
}
}
@@ -69,6 +70,9 @@ func UpsertConfigSubagent(configPath string, args map[string]interface{}) (map[s
if v := stringArgFromMap(args, "system_prompt"); v != "" {
subcfg.SystemPrompt = v
}
if v := stringArgFromMap(args, "system_prompt_file"); v != "" {
subcfg.SystemPromptFile = v
}
if v := stringArgFromMap(args, "memory_namespace"); v != "" {
subcfg.MemoryNamespace = v
}

View File

@@ -39,12 +39,13 @@ func (t *SubagentConfigTool) Parameters() map[string]interface{} {
"type": "string",
"description": "Optional preferred agent id seed for draft.",
},
"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"},
"memory_namespace": map[string]interface{}{"type": "string"},
"type": map[string]interface{}{"type": "string"},
"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"},
"system_prompt_file": map[string]interface{}{"type": "string"},
"memory_namespace": map[string]interface{}{"type": "string"},
"type": map[string]interface{}{"type": "string"},
"tool_allowlist": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{"type": "string"},

View File

@@ -16,21 +16,22 @@ import (
)
type SubagentProfile struct {
AgentID string `json:"agent_id"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
MemoryNamespace string `json:"memory_namespace,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
RetryBackoff int `json:"retry_backoff_ms,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"`
MaxTaskChars int `json:"max_task_chars,omitempty"`
MaxResultChars int `json:"max_result_chars,omitempty"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ManagedBy string `json:"managed_by,omitempty"`
AgentID string `json:"agent_id"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptFile string `json:"system_prompt_file,omitempty"`
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
MemoryNamespace string `json:"memory_namespace,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
RetryBackoff int `json:"retry_backoff_ms,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"`
MaxTaskChars int `json:"max_task_chars,omitempty"`
MaxResultChars int `json:"max_result_chars,omitempty"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ManagedBy string `json:"managed_by,omitempty"`
}
type SubagentProfileStore struct {
@@ -176,6 +177,7 @@ func normalizeSubagentProfile(in SubagentProfile) SubagentProfile {
}
p.Role = strings.TrimSpace(p.Role)
p.SystemPrompt = strings.TrimSpace(p.SystemPrompt)
p.SystemPromptFile = strings.TrimSpace(p.SystemPromptFile)
p.MemoryNamespace = normalizeSubagentIdentifier(p.MemoryNamespace)
if p.MemoryNamespace == "" {
p.MemoryNamespace = p.AgentID
@@ -343,19 +345,20 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro
status = "disabled"
}
return normalizeSubagentProfile(SubagentProfile{
AgentID: agentID,
Name: strings.TrimSpace(subcfg.DisplayName),
Role: strings.TrimSpace(subcfg.Role),
SystemPrompt: strings.TrimSpace(subcfg.SystemPrompt),
ToolAllowlist: append([]string(nil), subcfg.Tools.Allowlist...),
MemoryNamespace: strings.TrimSpace(subcfg.MemoryNamespace),
MaxRetries: subcfg.Runtime.MaxRetries,
RetryBackoff: subcfg.Runtime.RetryBackoffMs,
TimeoutSec: subcfg.Runtime.TimeoutSec,
MaxTaskChars: subcfg.Runtime.MaxTaskChars,
MaxResultChars: subcfg.Runtime.MaxResultChars,
Status: status,
ManagedBy: "config.json",
AgentID: agentID,
Name: strings.TrimSpace(subcfg.DisplayName),
Role: strings.TrimSpace(subcfg.Role),
SystemPrompt: strings.TrimSpace(subcfg.SystemPrompt),
SystemPromptFile: strings.TrimSpace(subcfg.SystemPromptFile),
ToolAllowlist: append([]string(nil), subcfg.Tools.Allowlist...),
MemoryNamespace: strings.TrimSpace(subcfg.MemoryNamespace),
MaxRetries: subcfg.Runtime.MaxRetries,
RetryBackoff: subcfg.Runtime.RetryBackoffMs,
TimeoutSec: subcfg.Runtime.TimeoutSec,
MaxTaskChars: subcfg.Runtime.MaxTaskChars,
MaxResultChars: subcfg.Runtime.MaxResultChars,
Status: status,
ManagedBy: "config.json",
})
}
@@ -382,11 +385,12 @@ func (t *SubagentProfileTool) Parameters() map[string]interface{} {
"type": "string",
"description": "Unique subagent id, e.g. coder/writer/tester",
},
"name": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"system_prompt": map[string]interface{}{"type": "string"},
"memory_namespace": map[string]interface{}{"type": "string"},
"status": map[string]interface{}{"type": "string", "description": "active|disabled"},
"name": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"system_prompt": map[string]interface{}{"type": "string"},
"system_prompt_file": map[string]interface{}{"type": "string"},
"memory_namespace": map[string]interface{}{"type": "string"},
"status": map[string]interface{}{"type": "string", "description": "active|disabled"},
"tool_allowlist": map[string]interface{}{
"type": "array",
"description": "Tool allowlist entries. Supports tool names, '*'/'all', and grouped tokens like 'group:files_read' or '@pipeline'.",
@@ -450,18 +454,19 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter
return "subagent profile already exists", nil
}
p := SubagentProfile{
AgentID: agentID,
Name: stringArg(args, "name"),
Role: stringArg(args, "role"),
SystemPrompt: stringArg(args, "system_prompt"),
MemoryNamespace: stringArg(args, "memory_namespace"),
Status: stringArg(args, "status"),
ToolAllowlist: parseStringList(args["tool_allowlist"]),
MaxRetries: profileIntArg(args, "max_retries"),
RetryBackoff: profileIntArg(args, "retry_backoff_ms"),
TimeoutSec: profileIntArg(args, "timeout_sec"),
MaxTaskChars: profileIntArg(args, "max_task_chars"),
MaxResultChars: profileIntArg(args, "max_result_chars"),
AgentID: agentID,
Name: stringArg(args, "name"),
Role: stringArg(args, "role"),
SystemPrompt: stringArg(args, "system_prompt"),
SystemPromptFile: stringArg(args, "system_prompt_file"),
MemoryNamespace: stringArg(args, "memory_namespace"),
Status: stringArg(args, "status"),
ToolAllowlist: parseStringList(args["tool_allowlist"]),
MaxRetries: profileIntArg(args, "max_retries"),
RetryBackoff: profileIntArg(args, "retry_backoff_ms"),
TimeoutSec: profileIntArg(args, "timeout_sec"),
MaxTaskChars: profileIntArg(args, "max_task_chars"),
MaxResultChars: profileIntArg(args, "max_result_chars"),
}
saved, err := t.store.Upsert(p)
if err != nil {
@@ -489,6 +494,9 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter
if _, ok := args["system_prompt"]; ok {
next.SystemPrompt = stringArg(args, "system_prompt")
}
if _, ok := args["system_prompt_file"]; ok {
next.SystemPromptFile = stringArg(args, "system_prompt_file")
}
if _, ok := args["memory_namespace"]; ok {
next.MemoryNamespace = stringArg(args, "memory_namespace")
}

View File

@@ -122,11 +122,12 @@ func TestSubagentProfileStoreReadsProfilesFromRuntimeConfig(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Agents.Subagents["coder"] = config.SubagentConfig{
Enabled: true,
DisplayName: "Code Agent",
Role: "coding",
SystemPrompt: "write code",
MemoryNamespace: "code-ns",
Enabled: true,
DisplayName: "Code Agent",
Role: "coding",
SystemPrompt: "write code",
SystemPromptFile: "agents/coder/AGENT.md",
MemoryNamespace: "code-ns",
Tools: config.SubagentToolsConfig{
Allowlist: []string{"read_file", "shell"},
},
@@ -154,6 +155,9 @@ func TestSubagentProfileStoreReadsProfilesFromRuntimeConfig(t *testing.T) {
if profile.Name != "Code Agent" || profile.Role != "coding" {
t.Fatalf("unexpected profile fields: %+v", profile)
}
if profile.SystemPromptFile != "agents/coder/AGENT.md" {
t.Fatalf("expected system_prompt_file from config, got: %s", profile.SystemPromptFile)
}
if len(profile.ToolAllowlist) != 2 {
t.Fatalf("expected merged allowlist, got: %v", profile.ToolAllowlist)
}

View File

@@ -3,11 +3,14 @@ package tools
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"clawgo/pkg/bus"
"clawgo/pkg/providers"
)
func TestSubagentSpawnEnforcesTaskQuota(t *testing.T) {
@@ -480,3 +483,58 @@ func waitSubagentDone(t *testing.T, manager *SubagentManager, timeout time.Durat
t.Fatalf("timeout waiting for subagent completion")
return nil
}
type captureProvider struct {
messages []providers.Message
}
func (p *captureProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
p.messages = append([]providers.Message(nil), messages...)
return &providers.LLMResponse{Content: "ok", FinishReason: "stop"}, nil
}
func (p *captureProvider) GetDefaultModel() string { return "test-model" }
func TestSubagentUsesConfiguredSystemPromptFile(t *testing.T) {
workspace := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, "agents", "coder"), 0755); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
if err := os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("workspace-policy"), 0644); err != nil {
t.Fatalf("write workspace AGENTS failed: %v", err)
}
if err := os.WriteFile(filepath.Join(workspace, "agents", "coder", "AGENT.md"), []byte("coder-policy-from-file"), 0644); err != nil {
t.Fatalf("write coder AGENT failed: %v", err)
}
provider := &captureProvider{}
manager := NewSubagentManager(provider, workspace, nil, nil)
if _, err := manager.ProfileStore().Upsert(SubagentProfile{
AgentID: "coder",
Status: "active",
SystemPrompt: "inline-fallback",
SystemPromptFile: "agents/coder/AGENT.md",
}); err != nil {
t.Fatalf("profile upsert failed: %v", err)
}
_, err := manager.Spawn(context.Background(), SubagentSpawnOptions{
Task: "implement feature",
AgentID: "coder",
OriginChannel: "cli",
OriginChatID: "direct",
})
if err != nil {
t.Fatalf("spawn failed: %v", err)
}
_ = waitSubagentDone(t, manager, 4*time.Second)
if len(provider.messages) == 0 {
t.Fatalf("expected provider to receive messages")
}
systemPrompt := provider.messages[0].Content
if !strings.Contains(systemPrompt, "coder-policy-from-file") {
t.Fatalf("expected system prompt to include configured file content, got: %s", systemPrompt)
}
if strings.Contains(systemPrompt, "inline-fallback") {
t.Fatalf("expected configured file content to take precedence over inline prompt, got: %s", systemPrompt)
}
}