This commit is contained in:
lpf
2026-02-23 16:38:00 +08:00
parent 95e9be18b8
commit b5430b9021
13 changed files with 1197 additions and 119 deletions

View File

@@ -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",

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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, "<!--") {
continue
}
if strings.HasPrefix(line, "- ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "- "))
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
if value == "" {
continue
}
value = strings.ReplaceAll(value, `\n`, "\n")
switch key {
case "autonomy_followup_report_no_focus":
dst.autonomyFollowUpReportNoFocus = value
case "autonomy_followup_silent_no_focus":
dst.autonomyFollowUpSilentNoFocus = value
case "autonomy_followup_report_with_focus":
dst.autonomyFollowUpReportWithFocus = value
case "autonomy_followup_silent_with_focus":
dst.autonomyFollowUpSilentWithFocus = value
case "autonomy_focus_bootstrap":
dst.autonomyFocusBootstrap = value
case "autolearn_round":
dst.autoLearnRound = value
case "autonomy_task_wrapper":
dst.autonomyTaskWrapper = value
case "progress_start":
dst.progressStart = value
case "progress_analysis":
dst.progressAnalysis = value
case "progress_execution_start":
dst.progressExecutionStart = value
case "progress_execution_round":
dst.progressExecutionRound = value
case "progress_tool_done":
dst.progressToolDone = value
case "progress_tool_failed":
dst.progressToolFailed = value
case "progress_finalization":
dst.progressFinalization = value
case "progress_done":
dst.progressDone = value
}
}
}
func renderLoopPromptTemplate(template string, vars map[string]string) string {
text := strings.TrimSpace(template)
for key, value := range vars {
placeholder := "{" + strings.TrimSpace(key) + "}"
text = strings.ReplaceAll(text, placeholder, value)
}
return strings.TrimSpace(text)
}
func isSyntheticMessage(msg bus.InboundMessage) bool {
@@ -1878,8 +2032,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
if strings.TrimSpace(userPrompt) == "" {
userPrompt = msg.Content
}
loopPrompts := al.loadLoopPromptTemplates()
if al.isAutonomyEnabled(msg.SessionKey) && run.controlEligible {
userPrompt = buildAutonomyTaskPrompt(userPrompt)
userPrompt = al.buildAutonomyTaskPrompt(userPrompt)
}
var progress *stageReporter
@@ -1896,8 +2051,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
})
},
}
progress.Publish(1, 5, "start", "I received your task and will clarify the goal and constraints first.")
progress.Publish(2, 5, "analysis", "I am building the context needed for execution.")
progress.Publish(1, 5, "start", loopPrompts.progressStart)
progress.Publish(2, 5, "analysis", loopPrompts.progressAnalysis)
}
// Update tool contexts
@@ -1912,7 +2067,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
}
history := al.sessions.GetHistory(msg.SessionKey)
history := pruneControlHistoryMessages(al.sessions.GetHistory(msg.SessionKey))
summary := al.sessions.GetSummary(msg.SessionKey)
messages := al.contextBuilder.BuildMessages(
@@ -1925,10 +2080,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
)
if progress != nil {
progress.Publish(3, 5, "execution", "I am starting step-by-step execution.")
progress.Publish(3, 5, "execution", loopPrompts.progressExecutionStart)
}
finalContent, iteration, err := al.runLLMToolLoop(ctx, messages, msg.SessionKey, false, progress)
finalContent, iteration, err := al.runLLMToolLoop(ctx, messages, msg.SessionKey, false, !isSyntheticMessage(msg), progress)
if err != nil {
if progress != nil {
progress.Publish(5, 5, "failure", err.Error())
@@ -1953,13 +2108,17 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
}
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
if !isSyntheticMessage(msg) {
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
}
// Use AddMessageFull to persist the complete assistant message, including thoughts/tool calls.
al.sessions.AddMessageFull(msg.SessionKey, providers.Message{
Role: "assistant",
Content: userContent,
})
if !isSyntheticMessage(msg) {
// Use AddMessageFull to persist the complete assistant message, including thoughts/tool calls.
al.sessions.AddMessageFull(msg.SessionKey, providers.Message{
Role: "assistant",
Content: userContent,
})
}
if err := al.persistSessionWithCompaction(ctx, msg.SessionKey); err != nil {
logger.WarnCF("agent", "Failed to save session metadata", map[string]interface{}{
@@ -1978,8 +2137,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
})
if progress != nil {
progress.Publish(4, 5, "finalization", "Final response is ready.")
progress.Publish(5, 5, "done", "Task completed.")
progress.Publish(4, 5, "finalization", loopPrompts.progressFinalization)
progress.Publish(5, 5, "done", loopPrompts.progressDone)
}
return userContent, nil
@@ -2024,7 +2183,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
}
// Build messages with the announce content
history := al.sessions.GetHistory(sessionKey)
history := pruneControlHistoryMessages(al.sessions.GetHistory(sessionKey))
summary := al.sessions.GetSummary(sessionKey)
messages := al.contextBuilder.BuildMessages(
history,
@@ -2035,7 +2194,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
originChatID,
)
finalContent, iteration, err := al.runLLMToolLoop(ctx, messages, sessionKey, true, nil)
finalContent, iteration, err := al.runLLMToolLoop(ctx, messages, sessionKey, true, false, nil)
if err != nil {
return "", err
}
@@ -2044,16 +2203,13 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
finalContent = "Background task completed."
}
// Save to session with system message marker
al.sessions.AddMessage(sessionKey, "user", fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content))
// If finalContent has no tool calls (i.e., the final LLM output),
// earlier steps were already stored via AddMessageFull in the loop.
// This AddMessageFull stores the final reply.
al.sessions.AddMessageFull(sessionKey, providers.Message{
Role: "assistant",
Content: finalContent,
})
systemSummary := al.summarizeSystemTaskResult(ctx, msg.Content, finalContent)
if strings.TrimSpace(systemSummary) != "" {
al.sessions.AddMessageFull(sessionKey, providers.Message{
Role: "assistant",
Content: systemSummary,
})
}
if err := al.persistSessionWithCompaction(ctx, sessionKey); err != nil {
logger.WarnCF("agent", "Failed to save session metadata", map[string]interface{}{
@@ -2071,14 +2227,122 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
return finalContent, nil
}
func (al *AgentLoop) summarizeSystemTaskResult(ctx context.Context, task, result string) string {
task = strings.TrimSpace(task)
result = strings.TrimSpace(result)
if result == "" {
return ""
}
policy := al.systemSummaryPolicy()
systemPrompt := al.withBootstrapPolicy(`Summarize a background/system task result for future context reuse.
Return concise markdown with these sections only:
` + strings.TrimSpace(policy.marker) + `
` + strings.TrimSpace(policy.completedPrefix) + ` ...
` + strings.TrimSpace(policy.changesPrefix) + ` ...
` + strings.TrimSpace(policy.outcomePrefix) + ` ...
Rules: <= 700 characters; one line per bullet; keep concrete facts only.`)
userPrompt := fmt.Sprintf("System task:\n%s\n\nExecution result:\n%s", truncate(task, 1200), truncate(result, 2600))
summaryCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
resp, err := al.callLLMWithModelFallback(summaryCtx, []providers.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
}, nil, map[string]interface{}{
"max_tokens": 260,
"temperature": 0.1,
})
if err == nil && resp != nil && strings.TrimSpace(resp.Content) != "" {
return truncate(strings.TrimSpace(resp.Content), 900)
}
return buildSystemTaskSummaryFallback(task, result, policy)
}
func buildSystemTaskSummaryFallback(task, result string, policy systemSummaryPolicy) string {
task = strings.TrimSpace(task)
result = strings.TrimSpace(result)
if result == "" {
return ""
}
lines := strings.Split(result, "\n")
completed := ""
changes := make([]string, 0, 3)
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if completed == "" {
completed = line
}
lower := strings.ToLower(line)
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || isNumberedSummaryLine(line) ||
strings.Contains(lower, "modified") || strings.Contains(lower, "updated") ||
strings.Contains(lower, "added") || strings.Contains(lower, "removed") ||
strings.Contains(lower, "changed") || strings.Contains(lower, "新增") ||
strings.Contains(lower, "修改") || strings.Contains(lower, "删除") {
changes = append(changes, line)
}
if len(changes) >= 3 {
break
}
}
if completed == "" {
completed = "Task executed."
}
changesText := "No explicit file-level changes detected."
if len(changes) > 0 {
changesText = strings.Join(changes, " | ")
}
outcome := truncate(result, 220)
return fmt.Sprintf("%s\n%s %s\n%s %s\n%s %s",
policy.marker,
policy.completedPrefix, truncate(completed, 220),
policy.changesPrefix, truncate(changesText, 320),
policy.outcomePrefix, outcome)
}
func isNumberedSummaryLine(line string) bool {
dot := strings.Index(line, ".")
if dot <= 0 || dot >= len(line)-1 {
return false
}
for i := 0; i < dot; i++ {
if line[i] < '0' || line[i] > '9' {
return false
}
}
return line[dot+1] == ' '
}
func (al *AgentLoop) systemSummaryPolicy() systemSummaryPolicy {
if al == nil || al.contextBuilder == nil {
return defaultSystemSummaryPolicy()
}
p := al.contextBuilder.summaryPolicy
if strings.TrimSpace(p.marker) == "" ||
strings.TrimSpace(p.completedPrefix) == "" ||
strings.TrimSpace(p.changesPrefix) == "" ||
strings.TrimSpace(p.outcomePrefix) == "" {
return defaultSystemSummaryPolicy()
}
return p
}
func (al *AgentLoop) runLLMToolLoop(
ctx context.Context,
messages []providers.Message,
sessionKey string,
systemMode bool,
persistHistory bool,
progress *stageReporter,
) (string, int, error) {
messages = sanitizeMessagesForToolCalling(messages)
loopPrompts := al.loadLoopPromptTemplates()
state := toolLoopState{}
@@ -2086,7 +2350,7 @@ func (al *AgentLoop) runLLMToolLoop(
state.iteration++
iteration := state.iteration
if progress != nil {
progress.Publish(3, 5, "execution", "正在执行下一轮。")
progress.Publish(3, 5, "execution", loopPrompts.progressExecutionRound)
}
providerToolDefs, err := buildProviderToolDefs(al.tools.GetDefinitions())
@@ -2153,7 +2417,7 @@ func (al *AgentLoop) runLLMToolLoop(
})
budget := al.computeToolLoopBudget(state)
outcome := al.actToolCalls(ctx, response.Content, response.ToolCalls, &messages, sessionKey, iteration, budget, systemMode, progress)
outcome := al.actToolCalls(ctx, response.Content, response.ToolCalls, &messages, sessionKey, iteration, budget, systemMode, progress, persistHistory)
state.lastToolResult = outcome.lastToolResult
if outcome.executedCalls > 0 && outcome.roundToolErrors == outcome.executedCalls {
state.consecutiveAllToolErrorRounds++
@@ -2330,6 +2594,7 @@ func (al *AgentLoop) actToolCalls(
budget toolLoopBudget,
systemMode bool,
progress *stageReporter,
persistHistory bool,
) toolActOutcome {
outcome := toolActOutcome{}
if len(toolCalls) == 0 {
@@ -2362,7 +2627,9 @@ func (al *AgentLoop) actToolCalls(
})
}
*messages = append(*messages, assistantMsg)
al.sessions.AddMessageFull(sessionKey, assistantMsg)
if persistHistory {
al.sessions.AddMessageFull(sessionKey, assistantMsg)
}
start := time.Now()
maxActDuration := budget.maxActDuration
@@ -2382,6 +2649,7 @@ func (al *AgentLoop) actToolCalls(
outcome.truncated = true
outcome.droppedCalls += len(execCalls) - len(results)
}
loopPrompts := al.loadLoopPromptTemplates()
for i, execRes := range results {
tc := execRes.call
@@ -2408,9 +2676,9 @@ func (al *AgentLoop) actToolCalls(
}
if progress != nil {
if err != nil {
progress.Publish(3, 5, "execution", "工具执行失败。")
progress.Publish(3, 5, "execution", loopPrompts.progressToolFailed)
} else {
progress.Publish(3, 5, "execution", "工具执行完成。")
progress.Publish(3, 5, "execution", loopPrompts.progressToolDone)
}
}
outcome.lastToolResult = result
@@ -2421,7 +2689,7 @@ func (al *AgentLoop) actToolCalls(
ToolCallID: tc.ID,
}
*messages = append(*messages, toolResultMsg)
if shouldPersistToolResultRecord(record, i, len(results)) {
if persistHistory && shouldPersistToolResultRecord(record, i, len(results)) {
al.sessions.AddMessageFull(sessionKey, toolResultMsg)
}
outcome.executedCalls++
@@ -2442,7 +2710,7 @@ func (al *AgentLoop) executeToolCalls(
if parallel {
return al.executeToolCallsBatchedParallel(ctx, execCalls, iteration, singleTimeout, systemMode, progress)
}
return al.executeToolCallsSerial(ctx, execCalls, iteration, singleTimeout, systemMode, progress)
return al.executeToolCallsSerial(ctx, execCalls, iteration, singleTimeout, systemMode)
}
func (al *AgentLoop) executeToolCallsBatchedParallel(
@@ -2463,7 +2731,7 @@ func (al *AgentLoop) executeToolCallsBatchedParallel(
}
if len(batch) <= 1 {
if len(batch) == 1 {
results = append(results, al.executeSingleToolCall(ctx, len(results), batch[0], iteration, singleTimeout, systemMode, progress))
results = append(results, al.executeSingleToolCall(ctx, len(results), batch[0], iteration, singleTimeout, systemMode))
}
continue
}
@@ -2479,7 +2747,6 @@ func (al *AgentLoop) executeToolCallsSerial(
iteration int,
singleTimeout time.Duration,
systemMode bool,
progress *stageReporter,
) []toolCallExecResult {
results := make([]toolCallExecResult, 0, len(execCalls))
for i, tc := range execCalls {
@@ -2488,7 +2755,7 @@ func (al *AgentLoop) executeToolCallsSerial(
return results
default:
}
res := al.executeSingleToolCall(ctx, i, tc, iteration, singleTimeout, systemMode, progress)
res := al.executeSingleToolCall(ctx, i, tc, iteration, singleTimeout, systemMode)
results = append(results, res)
}
return results
@@ -2505,7 +2772,7 @@ func (al *AgentLoop) executeToolCallsParallel(
results := make([]toolCallExecResult, len(execCalls))
limit := al.maxToolParallelCalls()
if limit <= 1 {
return al.executeToolCallsSerial(ctx, execCalls, iteration, singleTimeout, systemMode, progress)
return al.executeToolCallsSerial(ctx, execCalls, iteration, singleTimeout, systemMode)
}
if len(execCalls) < limit {
limit = len(execCalls)
@@ -2523,7 +2790,7 @@ func (al *AgentLoop) executeToolCallsParallel(
go func(i int, tc providers.ToolCall) {
defer wg.Done()
defer func() { <-sem }()
results[i] = al.executeSingleToolCall(ctx, i, tc, iteration, singleTimeout, systemMode, progress)
results[i] = al.executeSingleToolCall(ctx, i, tc, iteration, singleTimeout, systemMode)
}(i, tc)
}
wait:
@@ -2629,7 +2896,6 @@ func (al *AgentLoop) executeSingleToolCall(
iteration int,
singleTimeout time.Duration,
systemMode bool,
progress *stageReporter,
) toolCallExecResult {
if !systemMode {
safeArgs := sanitizeSensitiveToolArgs(tc.Arguments)
@@ -2647,13 +2913,6 @@ func (al *AgentLoop) executeSingleToolCall(
if err != nil {
result = fmt.Sprintf("Error: %v", err)
}
if progress != nil {
if err != nil {
progress.Publish(3, 5, "execution", "工具执行失败。")
} else {
progress.Publish(3, 5, "execution", "工具执行完成。")
}
}
return toolCallExecResult{
index: index,
call: tc,
@@ -3905,10 +4164,40 @@ func normalizeCompactionMode(raw string) string {
}
}
func isSyntheticUserPromptContent(content string) bool {
text := strings.ToLower(strings.TrimSpace(content))
if text == "" {
return false
}
if strings.HasPrefix(text, "[system:") {
return true
}
return false
}
func pruneControlHistoryMessages(messages []providers.Message) []providers.Message {
if len(messages) == 0 {
return nil
}
pruned := make([]providers.Message, 0, len(messages))
for _, msg := range messages {
role := strings.ToLower(strings.TrimSpace(msg.Role))
if role == "user" && isSyntheticUserPromptContent(msg.Content) {
continue
}
pruned = append(pruned, msg)
}
return pruned
}
func formatCompactionTranscript(messages []providers.Message, maxChars int) string {
if maxChars <= 0 || len(messages) == 0 {
return ""
}
messages = pruneControlHistoryMessages(messages)
if len(messages) == 0 {
return ""
}
lines := make([]string, 0, len(messages))
totalChars := 0
@@ -3991,6 +4280,7 @@ func shouldCompactBySize(summary string, history []providers.Message, maxTranscr
}
func estimateCompactionChars(summary string, history []providers.Message) int {
history = pruneControlHistoryMessages(history)
total := len(strings.TrimSpace(summary))
for _, msg := range history {
total += len(strings.TrimSpace(msg.Role)) + len(strings.TrimSpace(msg.Content)) + 6

View File

@@ -11,12 +11,21 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"clawgo/pkg/config"
"clawgo/pkg/logger"
)
const (
maxMemoryContextChars = 6000
maxLongTermMemoryChars = 2200
maxRecentNotesChars = 1600
maxMemoryLayerPartChars = 1200
maxMemoryDigestLines = 14
)
// MemoryStore manages persistent memory for the agent.
// - Long-term memory: memory/MEMORY.md
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
@@ -29,6 +38,7 @@ type MemoryStore struct {
includeProfile bool
includeProject bool
includeProcedure bool
mu sync.Mutex
}
// NewMemoryStore creates a new MemoryStore with the given workspace path.
@@ -120,10 +130,13 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
ms.mu.Lock()
defer ms.mu.Unlock()
if err := os.MkdirAll(ms.memoryDir, 0755); err != nil {
return err
}
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
return atomicWriteFile(ms.memoryFile, []byte(content), 0644)
}
// ReadToday reads today's daily note.
@@ -139,6 +152,9 @@ func (ms *MemoryStore) ReadToday() string {
// AppendToday appends content to today's daily note.
// If the file doesn't exist, it creates a new file with a date header.
func (ms *MemoryStore) AppendToday(content string) error {
ms.mu.Lock()
defer ms.mu.Unlock()
todayFile := ms.getTodayFile()
// Ensure month directory exists
@@ -147,22 +163,28 @@ func (ms *MemoryStore) AppendToday(content string) error {
return err
}
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
existingContent = string(data)
f, err := os.OpenFile(todayFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return err
}
var newContent string
if existingContent == "" {
payload := content
if info.Size() == 0 {
// Add header for new day
header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02"))
newContent = header + content
payload = header + content
} else {
// Append to existing content
newContent = existingContent + "\n" + content
payload = "\n" + content
}
return os.WriteFile(todayFile, []byte(newContent), 0644)
_, err = f.WriteString(payload)
return err
}
// GetRecentDailyNotes returns daily notes from the last N days.
@@ -209,13 +231,13 @@ func (ms *MemoryStore) GetMemoryContext() string {
// Long-term memory
longTerm := ms.ReadLongTerm()
if longTerm != "" {
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
parts = append(parts, "## Long-term Memory (Digest)\n\n"+compressMemoryForPrompt(longTerm, maxMemoryDigestLines, maxLongTermMemoryChars))
}
// Recent daily notes
recentNotes := ms.GetRecentDailyNotes(ms.recentDays)
if recentNotes != "" {
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
parts = append(parts, "## Recent Daily Notes (Digest)\n\n"+compressMemoryForPrompt(recentNotes, maxMemoryDigestLines, maxRecentNotesChars))
}
if len(parts) == 0 {
@@ -230,7 +252,7 @@ func (ms *MemoryStore) GetMemoryContext() string {
}
result += part
}
return fmt.Sprintf("# Memory\n\n%s", result)
return fmt.Sprintf("# Memory\n\n%s", truncateMemoryText(result, maxMemoryContextChars))
}
func (ms *MemoryStore) getLayeredContext() []string {
@@ -244,7 +266,7 @@ func (ms *MemoryStore) getLayeredContext() []string {
if strings.TrimSpace(content) == "" {
return
}
parts = append(parts, fmt.Sprintf("## %s\n\n%s", title, content))
parts = append(parts, fmt.Sprintf("## %s (Digest)\n\n%s", title, compressMemoryForPrompt(content, maxMemoryDigestLines, maxMemoryLayerPartChars)))
}
if ms.includeProfile {
@@ -258,3 +280,94 @@ func (ms *MemoryStore) getLayeredContext() []string {
}
return parts
}
func truncateMemoryText(content string, maxChars int) string {
if maxChars <= 0 {
return strings.TrimSpace(content)
}
trimmed := strings.TrimSpace(content)
runes := []rune(trimmed)
if len(runes) <= maxChars {
return trimmed
}
suffix := "\n\n...[truncated]"
suffixRunes := []rune(suffix)
if maxChars <= len(suffixRunes) {
return string(runes[:maxChars])
}
return strings.TrimSpace(string(runes[:maxChars-len(suffixRunes)])) + suffix
}
func compressMemoryForPrompt(content string, maxLines, maxChars int) string {
trimmed := strings.TrimSpace(content)
if trimmed == "" {
return ""
}
if maxLines <= 0 {
maxLines = maxMemoryDigestLines
}
lines := strings.Split(trimmed, "\n")
kept := make([]string, 0, maxLines)
inParagraph := false
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
inParagraph = false
continue
}
isHeading := strings.HasPrefix(line, "#")
isBullet := strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ")
isNumbered := isNumberedListLine(line)
if isHeading || isBullet || isNumbered {
kept = append(kept, line)
inParagraph = false
} else if !inParagraph {
// Keep only the first line of each paragraph to form a compact digest.
kept = append(kept, line)
inParagraph = true
}
if len(kept) >= maxLines {
break
}
}
if len(kept) == 0 {
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
kept = append(kept, line)
if len(kept) >= maxLines {
break
}
}
}
return truncateMemoryText(strings.Join(kept, "\n"), maxChars)
}
func isNumberedListLine(line string) bool {
dot := strings.Index(line, ".")
if dot <= 0 || dot >= len(line)-1 {
return false
}
for i := 0; i < dot; i++ {
if line[i] < '0' || line[i] > '9' {
return false
}
}
return line[dot+1] == ' '
}
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, perm); err != nil {
return err
}
return os.Rename(tmpPath, path)
}

38
pkg/agent/memory_test.go Normal file
View File

@@ -0,0 +1,38 @@
package agent
import (
"strings"
"testing"
)
func TestTruncateMemoryTextRuneSafe(t *testing.T) {
in := "你好世界这是一个测试"
out := truncateMemoryText(in, 6)
if strings.Contains(out, "<22>") {
t.Fatalf("expected rune-safe truncation, got invalid rune replacement: %q", out)
}
}
func TestCompressMemoryForPromptPrefersStructuredLines(t *testing.T) {
in := `
# Long-term Memory
plain paragraph line 1
plain paragraph line 2
- bullet one
- bullet two
another paragraph
`
out := compressMemoryForPrompt(in, 4, 200)
if !strings.Contains(out, "# Long-term Memory") {
t.Fatalf("expected heading in digest, got: %q", out)
}
if !strings.Contains(out, "- bullet one") {
t.Fatalf("expected bullet in digest, got: %q", out)
}
if strings.Contains(out, "plain paragraph line 2") {
t.Fatalf("expected paragraph compression to keep first line only, got: %q", out)
}
}

View File

@@ -0,0 +1,22 @@
package agent
import (
"strings"
"testing"
)
func TestBuildSystemTaskSummaryFallbackUsesPolicyPrefixes(t *testing.T) {
policy := defaultSystemSummaryPolicy()
policy.marker = "## Runtime Summary"
policy.completedPrefix = "- Done:"
policy.changesPrefix = "- Delta:"
policy.outcomePrefix = "- Result:"
out := buildSystemTaskSummaryFallback("task", "updated README.md\nbuild passed", policy)
if !strings.HasPrefix(out, "## Runtime Summary") {
t.Fatalf("expected custom marker, got: %s", out)
}
if !strings.Contains(out, "- Done:") || !strings.Contains(out, "- Delta:") || !strings.Contains(out, "- Result:") {
t.Fatalf("expected custom prefixes, got: %s", out)
}
}