From b5430b9021670320517ae809e8db0fac5796dcb0 Mon Sep 17 00:00:00 2001 From: lpf Date: Mon, 23 Feb 2026 16:38:00 +0800 Subject: [PATCH] fix --- config.example.json | 11 +- pkg/agent/context.go | 270 +++++++++++++- pkg/agent/context_system_summary_test.go | 97 +++++ pkg/agent/history_filter_test.go | 34 ++ pkg/agent/loop.go | 412 ++++++++++++++++++---- pkg/agent/memory.go | 141 +++++++- pkg/agent/memory_test.go | 38 ++ pkg/agent/system_summary_fallback_test.go | 22 ++ pkg/config/config.go | 44 ++- pkg/config/validate.go | 21 ++ pkg/tools/filesystem.go | 24 +- pkg/tools/memory.go | 100 +++++- pkg/tools/memory_test.go | 102 ++++++ 13 files changed, 1197 insertions(+), 119 deletions(-) create mode 100644 pkg/agent/context_system_summary_test.go create mode 100644 pkg/agent/history_filter_test.go create mode 100644 pkg/agent/memory_test.go create mode 100644 pkg/agent/system_summary_fallback_test.go create mode 100644 pkg/tools/memory_test.go diff --git a/config.example.json b/config.example.json index 54db4f8..7b3ba83 100644 --- a/config.example.json +++ b/config.example.json @@ -27,7 +27,16 @@ "run_state_ttl_seconds": 1800, "run_state_max": 500, "tool_parallel_safe_names": ["read_file", "list_files", "find_files", "grep_files", "memory_search", "web_search", "repo_map", "system_info"], - "tool_max_parallel_calls": 2 + "tool_max_parallel_calls": 2, + "system_summary": { + "marker": "## System Task Summary", + "completed_prefix": "- Completed:", + "changes_prefix": "- Changes:", + "outcome_prefix": "- Outcome:", + "completed_title": "Completed Actions", + "changes_title": "Change Summaries", + "outcomes_title": "Execution Outcomes" + } } } }, diff --git a/pkg/agent/context.go b/pkg/agent/context.go index d4f74ca..0555162 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -17,15 +17,18 @@ import ( ) type ContextBuilder struct { - workspace string - skillsLoader *skills.SkillsLoader - memory *MemoryStore - toolsSummary func() []string // Function to get tool summaries dynamically + workspace string + skillsLoader *skills.SkillsLoader + memory *MemoryStore + toolsSummary func() []string // Function to get tool summaries dynamically + summaryPolicy systemSummaryPolicy } const ( - maxInlineMediaFileBytes int64 = 5 * 1024 * 1024 - maxInlineMediaTotalBytes int64 = 12 * 1024 * 1024 + maxInlineMediaFileBytes int64 = 5 * 1024 * 1024 + maxInlineMediaTotalBytes int64 = 12 * 1024 * 1024 + maxSystemTaskSummaries = 4 + maxSystemTaskSummariesChars = 2400 ) func getGlobalConfigDir() string { @@ -36,7 +39,7 @@ func getGlobalConfigDir() string { return filepath.Join(home, ".clawgo") } -func NewContextBuilder(workspace string, memCfg config.MemoryConfig, toolsSummaryFunc func() []string) *ContextBuilder { +func NewContextBuilder(workspace string, memCfg config.MemoryConfig, summaryCfg config.SystemSummaryPolicyConfig, toolsSummaryFunc func() []string) *ContextBuilder { // Built-in skills: the current project's skills directory. // Use the skills/ directory under the current working directory. wd, _ := os.Getwd() @@ -44,10 +47,11 @@ func NewContextBuilder(workspace string, memCfg config.MemoryConfig, toolsSummar globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") return &ContextBuilder{ - workspace: workspace, - skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), - memory: NewMemoryStore(workspace, memCfg), - toolsSummary: toolsSummaryFunc, + workspace: workspace, + skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), + memory: NewMemoryStore(workspace, memCfg), + toolsSummary: toolsSummaryFunc, + summaryPolicy: systemSummaryPolicyFromConfig(summaryCfg), } } @@ -83,7 +87,7 @@ Your workspace is at: %s 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. -3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`, +3. **Memory** - When remembering something, write to %s/memory/MEMORY.md. Prompt memory context is digest-only; use memory_search to retrieve detailed notes when needed.`, now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) } @@ -133,7 +137,7 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md // Memory context memoryContext := cb.memory.GetMemoryContext() if memoryContext != "" { - parts = append(parts, "# Memory\n\n"+memoryContext) + parts = append(parts, memoryContext) } // Join with "---" separator @@ -163,6 +167,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str messages := []providers.Message{} systemPrompt := cb.BuildSystemPrompt() + filteredHistory, systemSummaries := extractSystemTaskSummariesFromHistoryWithPolicy(history, cb.summaryPolicy) // Add Current Session info if provided if channel != "" && chatID != "" { @@ -188,7 +193,13 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str }) if summary != "" { - systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary + summary = sanitizeSummaryForPrompt(summary) + if summary != "" { + systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary + } + } + if len(systemSummaries) > 0 { + systemPrompt += "\n\n## Recent System Task Summaries\n\n" + formatSystemTaskSummariesWithPolicy(systemSummaries, cb.summaryPolicy) } messages = append(messages, providers.Message{ @@ -196,7 +207,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str Content: systemPrompt, }) - messages = append(messages, history...) + messages = append(messages, filteredHistory...) userMsg := providers.Message{ Role: "user", @@ -210,6 +221,235 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str return messages } +func sanitizeSummaryForPrompt(summary string) string { + text := strings.TrimSpace(summary) + if text == "" { + return "" + } + lines := strings.Split(text, "\n") + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + lower := strings.ToLower(strings.TrimSpace(line)) + if strings.Contains(lower, "autonomy round ") || + strings.Contains(lower, "auto-learn round ") || + strings.Contains(lower, "autonomy mode started.") || + strings.Contains(lower, "[system:") { + continue + } + filtered = append(filtered, line) + } + return strings.TrimSpace(strings.Join(filtered, "\n")) +} + +func extractSystemTaskSummariesFromHistory(history []providers.Message) ([]providers.Message, []string) { + return extractSystemTaskSummariesFromHistoryWithPolicy(history, defaultSystemSummaryPolicy()) +} + +func extractSystemTaskSummariesFromHistoryWithPolicy(history []providers.Message, policy systemSummaryPolicy) ([]providers.Message, []string) { + if len(history) == 0 { + return nil, nil + } + filtered := make([]providers.Message, 0, len(history)) + summaries := make([]string, 0, maxSystemTaskSummaries) + for _, msg := range history { + if strings.EqualFold(strings.TrimSpace(msg.Role), "assistant") && isSystemTaskSummaryMessageWithPolicy(msg.Content, policy) { + summaries = append(summaries, strings.TrimSpace(msg.Content)) + continue + } + filtered = append(filtered, msg) + } + if len(summaries) > maxSystemTaskSummaries { + summaries = summaries[len(summaries)-maxSystemTaskSummaries:] + } + return filtered, summaries +} + +func isSystemTaskSummaryMessage(content string) bool { + return isSystemTaskSummaryMessageWithPolicy(content, defaultSystemSummaryPolicy()) +} + +func isSystemTaskSummaryMessageWithPolicy(content string, policy systemSummaryPolicy) bool { + text := strings.TrimSpace(content) + if text == "" { + return false + } + lower := strings.ToLower(text) + marker := strings.ToLower(strings.TrimSpace(policy.marker)) + completed := strings.ToLower(strings.TrimSpace(policy.completedPrefix)) + outcome := strings.ToLower(strings.TrimSpace(policy.outcomePrefix)) + return strings.HasPrefix(lower, marker) || + (strings.Contains(lower, marker) && strings.Contains(lower, completed) && strings.Contains(lower, outcome)) +} + +func formatSystemTaskSummaries(summaries []string) string { + return formatSystemTaskSummariesWithPolicy(summaries, defaultSystemSummaryPolicy()) +} + +func formatSystemTaskSummariesWithPolicy(summaries []string, policy systemSummaryPolicy) string { + if len(summaries) == 0 { + return "" + } + + completedItems := make([]string, 0, len(summaries)) + changeItems := make([]string, 0, len(summaries)) + outcomeItems := make([]string, 0, len(summaries)) + for _, raw := range summaries { + entry := parseSystemTaskSummaryWithPolicy(raw, policy) + if entry.completed != "" { + completedItems = append(completedItems, entry.completed) + } + if entry.changes != "" { + changeItems = append(changeItems, entry.changes) + } + if entry.outcome != "" { + outcomeItems = append(outcomeItems, entry.outcome) + } + } + + var sb strings.Builder + writeSection := func(title string, items []string) { + if len(items) == 0 { + return + } + if sb.Len() > 0 { + sb.WriteString("\n\n") + } + sb.WriteString("### " + title + "\n") + for i, item := range items { + sb.WriteString(fmt.Sprintf("- %d. %s\n", i+1, truncateString(item, maxSystemTaskSummariesChars))) + } + } + + writeSection(policy.completedSectionTitle, completedItems) + writeSection(policy.changesSectionTitle, changeItems) + writeSection(policy.outcomesSectionTitle, outcomeItems) + + if sb.Len() == 0 { + for i, s := range summaries { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("- %d. %s\n", i+1, truncateString(strings.TrimSpace(s), maxSystemTaskSummariesChars))) + } + } + return strings.TrimSpace(sb.String()) +} + +type systemTaskSummaryEntry struct { + completed string + changes string + outcome string +} + +func parseSystemTaskSummary(raw string) systemTaskSummaryEntry { + return parseSystemTaskSummaryWithPolicy(raw, defaultSystemSummaryPolicy()) +} + +func parseSystemTaskSummaryWithPolicy(raw string, policy systemSummaryPolicy) systemTaskSummaryEntry { + text := strings.TrimSpace(raw) + if text == "" { + return systemTaskSummaryEntry{} + } + lines := strings.Split(text, "\n") + entry := systemTaskSummaryEntry{} + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + switch { + case strings.HasPrefix(lower, strings.ToLower(policy.completedPrefix)): + entry.completed = strings.TrimSpace(trimmed[len(policy.completedPrefix):]) + case strings.HasPrefix(lower, strings.ToLower(policy.changesPrefix)): + entry.changes = strings.TrimSpace(trimmed[len(policy.changesPrefix):]) + case strings.HasPrefix(lower, strings.ToLower(policy.outcomePrefix)): + entry.outcome = strings.TrimSpace(trimmed[len(policy.outcomePrefix):]) + } + } + + firstUsefulLine := "" + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(policy.completedPrefix)) || + strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(policy.changesPrefix)) || + strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(policy.outcomePrefix)) { + continue + } + firstUsefulLine = trimmed + break + } + if firstUsefulLine == "" { + firstUsefulLine = truncateString(text, 160) + } + if entry.completed == "" { + entry.completed = firstUsefulLine + } + if entry.changes == "" { + entry.changes = "No explicit file-level changes noted." + } + if entry.outcome == "" { + entry.outcome = firstUsefulLine + } + return entry +} + +type systemSummaryPolicy struct { + marker string + completedPrefix string + changesPrefix string + outcomePrefix string + completedSectionTitle string + changesSectionTitle string + outcomesSectionTitle string +} + +func defaultSystemSummaryPolicy() systemSummaryPolicy { + return systemSummaryPolicy{ + marker: "## System Task Summary", + completedPrefix: "- Completed:", + changesPrefix: "- Changes:", + outcomePrefix: "- Outcome:", + completedSectionTitle: "Completed Actions", + changesSectionTitle: "Change Summaries", + outcomesSectionTitle: "Execution Outcomes", + } +} + +func systemSummaryPolicyFromConfig(cfg config.SystemSummaryPolicyConfig) systemSummaryPolicy { + p := defaultSystemSummaryPolicy() + p.marker = strings.TrimSpace(cfg.Marker) + p.completedPrefix = strings.TrimSpace(cfg.CompletedPrefix) + p.changesPrefix = strings.TrimSpace(cfg.ChangesPrefix) + p.outcomePrefix = strings.TrimSpace(cfg.OutcomePrefix) + p.completedSectionTitle = strings.TrimSpace(cfg.CompletedTitle) + p.changesSectionTitle = strings.TrimSpace(cfg.ChangesTitle) + p.outcomesSectionTitle = strings.TrimSpace(cfg.OutcomesTitle) + if strings.TrimSpace(p.marker) == "" { + p.marker = defaultSystemSummaryPolicy().marker + } + if strings.TrimSpace(p.completedPrefix) == "" { + p.completedPrefix = defaultSystemSummaryPolicy().completedPrefix + } + if strings.TrimSpace(p.changesPrefix) == "" { + p.changesPrefix = defaultSystemSummaryPolicy().changesPrefix + } + if strings.TrimSpace(p.outcomePrefix) == "" { + p.outcomePrefix = defaultSystemSummaryPolicy().outcomePrefix + } + if strings.TrimSpace(p.completedSectionTitle) == "" { + p.completedSectionTitle = defaultSystemSummaryPolicy().completedSectionTitle + } + if strings.TrimSpace(p.changesSectionTitle) == "" { + p.changesSectionTitle = defaultSystemSummaryPolicy().changesSectionTitle + } + if strings.TrimSpace(p.outcomesSectionTitle) == "" { + p.outcomesSectionTitle = defaultSystemSummaryPolicy().outcomesSectionTitle + } + return p +} + func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message { messages = append(messages, providers.Message{ Role: "tool", diff --git a/pkg/agent/context_system_summary_test.go b/pkg/agent/context_system_summary_test.go new file mode 100644 index 0000000..210fd07 --- /dev/null +++ b/pkg/agent/context_system_summary_test.go @@ -0,0 +1,97 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + + "clawgo/pkg/config" + "clawgo/pkg/providers" +) + +func TestExtractSystemTaskSummariesFromHistory(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "## System Task Summary\n- Completed: A\n- Changes: B\n- Outcome: C"}, + {Role: "assistant", Content: "normal assistant reply"}, + } + + filtered, summaries := extractSystemTaskSummariesFromHistory(history) + if len(summaries) != 1 { + t.Fatalf("expected one summary, got %d", len(summaries)) + } + if len(filtered) != 2 { + t.Fatalf("expected summary message removed from history, got %d entries", len(filtered)) + } +} + +func TestExtractSystemTaskSummariesKeepsRecentN(t *testing.T) { + history := make([]providers.Message, 0, maxSystemTaskSummaries+2) + for i := 0; i < maxSystemTaskSummaries+2; i++ { + history = append(history, providers.Message{ + Role: "assistant", + Content: fmt.Sprintf("## System Task Summary\n- Completed: task-%d\n- Changes: x\n- Outcome: ok", i), + }) + } + + _, summaries := extractSystemTaskSummariesFromHistory(history) + if len(summaries) != maxSystemTaskSummaries { + t.Fatalf("expected %d summaries, got %d", maxSystemTaskSummaries, len(summaries)) + } + if !strings.Contains(summaries[0], "task-2") { + t.Fatalf("expected oldest retained summary to be task-2, got: %s", summaries[0]) + } +} + +func TestFormatSystemTaskSummariesStructuredSections(t *testing.T) { + summaries := []string{ + "## System Task Summary\n- Completed: update deps\n- Changes: modified go.mod\n- Outcome: build passed", + "## System Task Summary\n- Completed: cleanup\n- Outcome: no action needed", + } + + out := formatSystemTaskSummaries(summaries) + if !strings.Contains(out, "### Completed Actions") { + t.Fatalf("expected completed section, got: %s", out) + } + if !strings.Contains(out, "### Change Summaries") { + t.Fatalf("expected change section, got: %s", out) + } + if !strings.Contains(out, "### Execution Outcomes") { + t.Fatalf("expected outcome section, got: %s", out) + } + if !strings.Contains(out, "No explicit file-level changes noted.") { + t.Fatalf("expected fallback changes text, got: %s", out) + } +} + +func TestSystemSummaryPolicyFromConfig(t *testing.T) { + cfg := config.SystemSummaryPolicyConfig{ + CompletedTitle: "完成事项", + ChangesTitle: "变更事项", + OutcomesTitle: "执行结果", + CompletedPrefix: "- Done:", + ChangesPrefix: "- Delta:", + OutcomePrefix: "- Result:", + Marker: "## My Task Summary", + } + p := systemSummaryPolicyFromConfig(cfg) + if p.completedSectionTitle != "完成事项" || p.changesSectionTitle != "变更事项" || p.outcomesSectionTitle != "执行结果" { + t.Fatalf("section titles override failed: %#v", p) + } + if p.completedPrefix != "- Done:" || p.changesPrefix != "- Delta:" || p.outcomePrefix != "- Result:" || p.marker != "## My Task Summary" { + t.Fatalf("field prefixes override failed: %#v", p) + } +} + +func TestParseSystemTaskSummaryWithCustomPolicy(t *testing.T) { + p := defaultSystemSummaryPolicy() + p.completedPrefix = "- Done:" + p.changesPrefix = "- Delta:" + p.outcomePrefix = "- Result:" + + raw := "## System Task Summary\n- Done: sync docs\n- Delta: modified README.md\n- Result: success" + entry := parseSystemTaskSummaryWithPolicy(raw, p) + if entry.completed != "sync docs" || entry.changes != "modified README.md" || entry.outcome != "success" { + t.Fatalf("unexpected parsed entry: %#v", entry) + } +} diff --git a/pkg/agent/history_filter_test.go b/pkg/agent/history_filter_test.go new file mode 100644 index 0000000..0c96c21 --- /dev/null +++ b/pkg/agent/history_filter_test.go @@ -0,0 +1,34 @@ +package agent + +import ( + "testing" + + "clawgo/pkg/providers" +) + +func TestPruneControlHistoryMessagesDoesNotDropRealUserContent(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "autonomy round 3 is failing in my app and I need debugging help"}, + {Role: "assistant", Content: "Let's inspect logs first."}, + } + + pruned := pruneControlHistoryMessages(history) + if len(pruned) != 2 { + t.Fatalf("expected real user content to be preserved, got %d messages", len(pruned)) + } +} + +func TestPruneControlHistoryMessagesDropsSyntheticPromptOnly(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "[system:autonomy] internal control prompt"}, + {Role: "assistant", Content: "Background task completed."}, + } + + pruned := pruneControlHistoryMessages(history) + if len(pruned) != 1 { + t.Fatalf("expected only synthetic user prompt to be removed, got %d messages", len(pruned)) + } + if pruned[0].Role != "assistant" { + t.Fatalf("expected assistant message to remain, got role=%s", pruned[0].Role) + } +} diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 1473b1c..e25f2ff 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -259,6 +259,24 @@ type StartupSelfCheckReport struct { CompactedSessions int } +type loopPromptTemplates struct { + autonomyFollowUpReportNoFocus string + autonomyFollowUpSilentNoFocus string + autonomyFollowUpReportWithFocus string + autonomyFollowUpSilentWithFocus string + autonomyFocusBootstrap string + autoLearnRound string + autonomyTaskWrapper string + progressStart string + progressAnalysis string + progressExecutionStart string + progressExecutionRound string + progressToolDone string + progressToolFailed string + progressFinalization string + progressDone string +} + type tokenUsageTotals struct { input int output int @@ -575,7 +593,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers model: defaultModel, maxIterations: cfg.Agents.Defaults.MaxToolIterations, sessions: sessionsManager, - contextBuilder: NewContextBuilder(workspace, cfg.Memory, func() []string { return toolsRegistry.GetSummaries() }), + contextBuilder: NewContextBuilder(workspace, cfg.Memory, cfg.Agents.Defaults.RuntimeControl.SystemSummary, func() []string { return toolsRegistry.GetSummaries() }), tools: toolsRegistry, orchestrator: orchestrator, compactionCfg: cfg.Agents.Defaults.ContextCompaction, @@ -942,7 +960,7 @@ func (al *AgentLoop) startAutonomy(ctx context.Context, msg bus.InboundMessage, SenderID: "autonomy", ChatID: msg.ChatID, SessionKey: msg.SessionKey, - Content: buildAutonomyFocusPrompt(s.focus), + Content: al.buildAutonomyFocusPrompt(s.focus), Metadata: map[string]string{ "source": "autonomy", "round": "0", @@ -1077,7 +1095,7 @@ func (al *AgentLoop) maybeRunAutonomyRound(msg bus.InboundMessage) bool { SenderID: "autonomy", ChatID: msg.ChatID, SessionKey: msg.SessionKey, - Content: buildAutonomyFollowUpPrompt(round, focus, reportDue), + Content: al.buildAutonomyFollowUpPrompt(round, focus, reportDue), Metadata: map[string]string{ "source": "autonomy", "round": strconv.Itoa(round), @@ -1098,23 +1116,37 @@ func (al *AgentLoop) finishAutonomyRound(sessionKey string) { } } -func buildAutonomyFollowUpPrompt(round int, focus string, reportDue bool) string { +func (al *AgentLoop) buildAutonomyFollowUpPrompt(round int, focus string, reportDue bool) string { + prompts := al.loadLoopPromptTemplates() focus = strings.TrimSpace(focus) if focus == "" && reportDue { - return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Based on the current session context and completed work, autonomously complete one high-value next step and report progress or results in natural language.", round) + return renderLoopPromptTemplate(prompts.autonomyFollowUpReportNoFocus, map[string]string{ + "round": strconv.Itoa(round), + }) } if focus == "" && !reportDue { - return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Based on the current session context and completed work, autonomously complete one high-value next step. This round is execution-only; do not send an external reply.", round) + return renderLoopPromptTemplate(prompts.autonomyFollowUpSilentNoFocus, map[string]string{ + "round": strconv.Itoa(round), + }) } if reportDue { - return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Prioritize progress around the focus \"%s\"; if that focus is complete, explain and move to another high-value next step. After completion, report progress or results in natural language.", round, focus) + return renderLoopPromptTemplate(prompts.autonomyFollowUpReportWithFocus, map[string]string{ + "round": strconv.Itoa(round), + "focus": focus, + }) } - return fmt.Sprintf("Autonomy round %d: the user has not provided new input yet. Prioritize progress around the focus \"%s\"; if that focus is complete, explain and move to another high-value next step. This round is execution-only; do not send an external reply.", round, focus) + return renderLoopPromptTemplate(prompts.autonomyFollowUpSilentWithFocus, map[string]string{ + "round": strconv.Itoa(round), + "focus": focus, + }) } -func buildAutonomyFocusPrompt(focus string) string { +func (al *AgentLoop) buildAutonomyFocusPrompt(focus string) string { + prompts := al.loadLoopPromptTemplates() focus = strings.TrimSpace(focus) - return fmt.Sprintf("Autonomy mode started. For this round, prioritize the focus \"%s\": clarify the round goal first, then execute and report progress and results.", focus) + return renderLoopPromptTemplate(prompts.autonomyFocusBootstrap, map[string]string{ + "focus": focus, + }) } func (al *AgentLoop) startAutoLearner(ctx context.Context, msg bus.InboundMessage, interval time.Duration) { @@ -1164,7 +1196,7 @@ func (al *AgentLoop) runAutoLearnerLoop(ctx context.Context, msg bus.InboundMess SenderID: "autolearn", ChatID: msg.ChatID, SessionKey: msg.SessionKey, - Content: buildAutoLearnPrompt(round), + Content: al.buildAutoLearnPrompt(round), Metadata: map[string]string{ "source": "autolearn", "round": strconv.Itoa(round), @@ -1230,12 +1262,134 @@ func (al *AgentLoop) stopAutoLearner(sessionKey string) bool { return true } -func buildAutoLearnPrompt(round int) string { - return fmt.Sprintf("Auto-learn round %d: no user task is required. Based on current session and project context, choose and complete one high-value small task autonomously. Requirements: 1) define the learning goal for this round; 2) call tools when needed; 3) write key conclusions to memory/MEMORY.md; 4) output a concise progress report.", round) +func (al *AgentLoop) buildAutoLearnPrompt(round int) string { + prompts := al.loadLoopPromptTemplates() + return renderLoopPromptTemplate(prompts.autoLearnRound, map[string]string{ + "round": strconv.Itoa(round), + }) } -func buildAutonomyTaskPrompt(task string) string { - return fmt.Sprintf("Enable autonomous execution strategy. Proceed with the task directly, report progress naturally at key points, and finally provide results plus next-step suggestions.\n\nUser task: %s", strings.TrimSpace(task)) +func (al *AgentLoop) buildAutonomyTaskPrompt(task string) string { + prompts := al.loadLoopPromptTemplates() + return renderLoopPromptTemplate(prompts.autonomyTaskWrapper, map[string]string{ + "task": strings.TrimSpace(task), + }) +} + +func defaultLoopPromptTemplates() loopPromptTemplates { + return loopPromptTemplates{ + autonomyFollowUpReportNoFocus: "Autonomy round {round}: first complete the current active task. After it is complete, you may continue with one closely related next step and report progress.", + autonomyFollowUpSilentNoFocus: "Autonomy round {round}: first complete the current active task. After it is complete, you may continue with one closely related next step. If blocked, stop this round without external reply.", + autonomyFollowUpReportWithFocus: "Autonomy round {round}: first complete focus \"{focus}\". After that focus is complete, you may extend with one closely related next step; avoid unrelated branches. If blocked, report blocker and pause.", + autonomyFollowUpSilentWithFocus: "Autonomy round {round}: first complete focus \"{focus}\". After that focus is complete, you may extend with one closely related next step; avoid unrelated branches. If blocked, stop this round without external reply.", + autonomyFocusBootstrap: "Autonomy mode started. Prioritize focus \"{focus}\" first. Once complete, extension is allowed only to directly related next steps.", + autoLearnRound: "Auto-learn round {round}: choose one small bounded task and complete it. If finished with remaining capacity, you may do one directly related extension step, then stop.", + autonomyTaskWrapper: "Execute the user task below first. After completion, you may continue with directly related improvements. Avoid unrelated side tasks.\n\nUser task: {task}", + progressStart: "I received your task and will clarify the goal and constraints first.", + progressAnalysis: "I am building the context needed for execution.", + progressExecutionStart: "I am starting step-by-step execution.", + progressExecutionRound: "Starting another execution round.", + progressToolDone: "Tool execution completed.", + progressToolFailed: "Tool execution failed.", + progressFinalization: "Final response is ready.", + progressDone: "Task completed.", + } +} + +func (al *AgentLoop) loadLoopPromptTemplates() loopPromptTemplates { + prompts := defaultLoopPromptTemplates() + if al == nil || strings.TrimSpace(al.workspace) == "" { + return prompts + } + + for _, filename := range []string{"AGENTS.md", "USER.md"} { + filePath := filepath.Join(al.workspace, filename) + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + applyLoopPromptOverrides(&prompts, string(data)) + } + return prompts +} + +func applyLoopPromptOverrides(dst *loopPromptTemplates, content string) { + if dst == nil { + return + } + const sectionHeader = "## CLAWGO_LOOP_PROMPTS" + lines := strings.Split(content, "\n") + inSection := false + + for _, raw := range lines { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "## ") { + if strings.EqualFold(line, sectionHeader) { + inSection = true + continue + } + if inSection { + break + } + } + if !inSection || line == "" || strings.HasPrefix(line, "