mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 18:17:29 +08:00
fix
This commit is contained in:
@@ -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",
|
||||
|
||||
97
pkg/agent/context_system_summary_test.go
Normal file
97
pkg/agent/context_system_summary_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
34
pkg/agent/history_filter_test.go
Normal file
34
pkg/agent/history_filter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
38
pkg/agent/memory_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
22
pkg/agent/system_summary_fallback_test.go
Normal file
22
pkg/agent/system_summary_fallback_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user