diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index e27d0ce..1fd3dd1 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -573,32 +573,7 @@ func summarizeDialogTemplateChanges(oldCfg, newCfg *config.Config) []string { if oldCfg == nil || newCfg == nil { return nil } - type pair struct { - name string - a string - b string - } - oldT := oldCfg.Agents.Defaults.Texts - newT := newCfg.Agents.Defaults.Texts - checks := []pair{ - {name: "system_rewrite_template", a: oldT.SystemRewriteTemplate, b: newT.SystemRewriteTemplate}, - {name: "lang_usage", a: oldT.LangUsage, b: newT.LangUsage}, - {name: "lang_invalid", a: oldT.LangInvalid, b: newT.LangInvalid}, - {name: "lang_updated_template", a: oldT.LangUpdatedTemplate, b: newT.LangUpdatedTemplate}, - {name: "runtime_compaction_note", a: oldT.RuntimeCompactionNote, b: newT.RuntimeCompactionNote}, - {name: "startup_compaction_note", a: oldT.StartupCompactionNote, b: newT.StartupCompactionNote}, - {name: "autonomy_completion_template", a: oldT.AutonomyCompletionTemplate, b: newT.AutonomyCompletionTemplate}, - {name: "autonomy_blocked_template", a: oldT.AutonomyBlockedTemplate, b: newT.AutonomyBlockedTemplate}, - } out := make([]string, 0) - for _, c := range checks { - if strings.TrimSpace(c.a) != strings.TrimSpace(c.b) { - out = append(out, c.name) - } - } - if strings.Join(oldT.AutonomyImportantKeywords, "|") != strings.Join(newT.AutonomyImportantKeywords, "|") { - out = append(out, "autonomy_important_keywords") - } if oldCfg.Agents.Defaults.Heartbeat.PromptTemplate != newCfg.Agents.Defaults.Heartbeat.PromptTemplate { out = append(out, "heartbeat.prompt_template") } @@ -991,9 +966,6 @@ func buildAutonomyEngine(cfg *config.Config, msgBus *bus.MessageBus) *autonomy.E WaitingResumeDebounceSec: a.WaitingResumeDebounceSec, IdleRoundBudgetReleaseSec: idleRoundBudgetReleaseSec, AllowedTaskKeywords: a.AllowedTaskKeywords, - ImportantKeywords: cfg.Agents.Defaults.Texts.AutonomyImportantKeywords, - CompletionTemplate: cfg.Agents.Defaults.Texts.AutonomyCompletionTemplate, - BlockedTemplate: cfg.Agents.Defaults.Texts.AutonomyBlockedTemplate, EKGConsecutiveErrorThreshold: a.EKGConsecutiveErrorThreshold, Workspace: cfg.WorkspacePath(), DefaultNotifyChannel: notifyChannel, diff --git a/cmd/clawgo/cmd_status.go b/cmd/clawgo/cmd_status.go index f1bf756..5d51546 100644 --- a/cmd/clawgo/cmd_status.go +++ b/cmd/clawgo/cmd_status.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "clawgo/pkg/config" "clawgo/pkg/nodes" "clawgo/pkg/providers" ) + func statusCmd() { cfg, err := loadConfig() if err != nil { @@ -75,7 +75,6 @@ func statusCmd() { cfg.Agents.Defaults.Heartbeat.EverySec, cfg.Agents.Defaults.Heartbeat.AckMaxChars, ) - printTemplateStatus(cfg) fmt.Printf("Cron Runtime: workers=%d sleep=%d-%ds\n", cfg.Cron.MaxWorkers, cfg.Cron.MinSleepSec, @@ -167,12 +166,24 @@ func statusCmd() { if n.Online { online++ } - if n.Capabilities.Run { caps["run"]++ } - if n.Capabilities.Model { caps["model"]++ } - if n.Capabilities.Camera { caps["camera"]++ } - if n.Capabilities.Screen { caps["screen"]++ } - if n.Capabilities.Location { caps["location"]++ } - if n.Capabilities.Canvas { caps["canvas"]++ } + if n.Capabilities.Run { + caps["run"]++ + } + if n.Capabilities.Model { + caps["model"]++ + } + if n.Capabilities.Camera { + caps["camera"]++ + } + if n.Capabilities.Screen { + caps["screen"]++ + } + if n.Capabilities.Location { + caps["location"]++ + } + if n.Capabilities.Canvas { + caps["canvas"]++ + } } fmt.Printf("Nodes: total=%d online=%d\n", len(ns), online) fmt.Printf("Nodes Capabilities: run=%d model=%d camera=%d screen=%d location=%d canvas=%d\n", caps["run"], caps["model"], caps["camera"], caps["screen"], caps["location"], caps["canvas"]) @@ -186,30 +197,6 @@ func statusCmd() { } } -func printTemplateStatus(cfg *config.Config) { - if cfg == nil { - return - } - defaults := config.DefaultConfig().Agents.Defaults.Texts - cur := cfg.Agents.Defaults.Texts - fmt.Println("Dialog Templates:") - printTemplateField("system_rewrite_template", cur.SystemRewriteTemplate, defaults.SystemRewriteTemplate) - printTemplateField("lang_usage", cur.LangUsage, defaults.LangUsage) - printTemplateField("lang_invalid", cur.LangInvalid, defaults.LangInvalid) - printTemplateField("runtime_compaction_note", cur.RuntimeCompactionNote, defaults.RuntimeCompactionNote) - printTemplateField("startup_compaction_note", cur.StartupCompactionNote, defaults.StartupCompactionNote) - printTemplateField("autonomy_completion_template", cur.AutonomyCompletionTemplate, defaults.AutonomyCompletionTemplate) - printTemplateField("autonomy_blocked_template", cur.AutonomyBlockedTemplate, defaults.AutonomyBlockedTemplate) -} - -func printTemplateField(name, current, def string) { - state := "custom" - if strings.TrimSpace(current) == strings.TrimSpace(def) { - state = "default" - } - fmt.Printf(" %s: %s\n", name, state) -} - func summarizeAutonomyActions(statsJSON []byte) string { var payload struct { Counts map[string]int `json:"counts"` @@ -272,7 +259,9 @@ func autonomyControlState(workspace string) string { } ctrlPath := filepath.Join(memDir, "autonomy.control.json") if data, err := os.ReadFile(ctrlPath); err == nil { - var c struct{ Enabled bool `json:"enabled"` } + var c struct { + Enabled bool `json:"enabled"` + } if json.Unmarshal(data, &c) == nil { if c.Enabled { return "enabled" diff --git a/config.example.json b/config.example.json index 4705c70..210f501 100644 --- a/config.example.json +++ b/config.example.json @@ -11,7 +11,7 @@ "enabled": true, "every_sec": 1800, "ack_max_chars": 64, - "prompt_template": "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK." + "prompt_template": "" }, "autonomy": { "enabled": false, @@ -31,23 +31,6 @@ "allowed_task_keywords": [], "ekg_consecutive_error_threshold": 3 }, - "texts": { - "no_response_fallback": "I've completed processing but have no response to give.", - "think_only_fallback": "Thinking process completed.", - "memory_recall_keywords": ["remember", "记得", "上次", "之前", "偏好", "preference", "todo", "待办", "决定", "decision"], - "lang_usage": "Usage: /lang ", - "lang_invalid": "Invalid language code.", - "lang_updated_template": "Language preference updated to %s", - "subagents_none": "No subagents.", - "sessions_none": "No sessions.", - "unsupported_action": "unsupported action", - "system_rewrite_template": "Rewrite the following internal system update in concise user-facing language:\n\n%s", - "runtime_compaction_note": "[runtime-compaction] removed %d old messages, kept %d recent messages", - "startup_compaction_note": "[startup-compaction] removed %d old messages, kept %d recent messages", - "autonomy_important_keywords": ["urgent", "重要", "付款", "payment", "上线", "release", "deadline", "截止"], - "autonomy_completion_template": "✅ 已完成:%s\n回复“继续 %s”可继续下一步。", - "autonomy_blocked_template": "⚠️ 任务受阻:%s(%s)\n回复“继续 %s”我会重试。" - }, "context_compaction": { "enabled": true, "mode": "summary", diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 0b6f0b9..d3bbba2 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -67,11 +67,8 @@ Your workspace is at: %s - Daily Notes: %s/memory/YYYY-MM-DD.md - Skills: %s/skills/{skill-name}/SKILL.md -%s - -Always be helpful, accurate, and concise. When using tools, explain what you're doing. -When remembering something long-term, write to %s/MEMORY.md and use daily notes at %s/memory/YYYY-MM-DD.md for short-term logs.`, - now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath, workspacePath) +%s`, + now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection) } func (cb *ContextBuilder) buildToolsSection() string { @@ -107,20 +104,10 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { parts = append(parts, bootstrapContent) } - // Skills - OpenClaw-aligned selection protocol + available skill catalog + // Skills catalog (selection behavior is defined in workspace AGENTS.md/SOUL.md). skillsSummary := cb.skillsLoader.BuildSkillsSummary() if skillsSummary != "" { - parts = append(parts, fmt.Sprintf(`# Skills (mandatory protocol) - -Before replying: scan entries in . -- If exactly one skill clearly applies: read its SKILL.md (via read tool) and follow it. -- If multiple could apply: choose the most specific one, then read/follow it. -- If none clearly apply: do not read any SKILL.md. -Constraints: -- Never read more than one skill up front. -- If SKILL.md references relative paths, resolve against the skill directory. - -%s`, skillsSummary)) + parts = append(parts, fmt.Sprintf("## Skills Catalog\n\n%s", skillsSummary)) } // Memory context @@ -129,11 +116,6 @@ Constraints: parts = append(parts, memoryContext) } - parts = append(parts, `# Execution & Reply Policy -- Default behavior: execute first, then report. -- Avoid empty/meta fallback replies. -- For commit/push intents, treat as one transaction and return commit hash + push result.`) - // Join with "---" separator return strings.Join(parts, "\n\n---\n\n") } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bcaf5eb..0b1a472 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -11,11 +11,11 @@ import ( "encoding/json" "fmt" "hash/fnv" + "math" "os" "path/filepath" "regexp" "runtime" - "strconv" "strings" "sync" "time" @@ -34,41 +34,31 @@ import ( ) type AgentLoop struct { - bus *bus.MessageBus - provider providers.LLMProvider - workspace string - model string - maxIterations int - sessions *session.SessionManager - contextBuilder *ContextBuilder - tools *tools.ToolRegistry - compactionEnabled bool - compactionTrigger int - compactionKeepRecent int - heartbeatAckMaxChars int - memoryRecallKeywords []string - noResponseFallback string - thinkOnlyFallback string - langUsage string - langInvalid string - langUpdatedTemplate string - runtimeCompactionNote string - startupCompactionNote string - systemRewriteTemplate string - audit *triggerAudit - running bool - intentMu sync.RWMutex - intentHints map[string]string - sessionScheduler *SessionScheduler - providerNames []string - providerPool map[string]providers.LLMProvider - providerResponses map[string]config.ProviderResponsesConfig - telegramStreaming bool - ekg *ekg.Engine - providerMu sync.RWMutex - sessionProvider map[string]string - streamMu sync.Mutex - sessionStreamed map[string]bool + bus *bus.MessageBus + provider providers.LLMProvider + workspace string + model string + maxIterations int + sessions *session.SessionManager + contextBuilder *ContextBuilder + tools *tools.ToolRegistry + compactionEnabled bool + compactionTrigger int + compactionKeepRecent int + heartbeatAckMaxChars int + heartbeatAckToken string + audit *triggerAudit + running bool + sessionScheduler *SessionScheduler + providerNames []string + providerPool map[string]providers.LLMProvider + providerResponses map[string]config.ProviderResponsesConfig + telegramStreaming bool + ekg *ekg.Engine + providerMu sync.RWMutex + sessionProvider map[string]string + streamMu sync.Mutex + sessionStreamed map[string]bool } // StartupCompactionReport provides startup memory/session maintenance stats. @@ -181,7 +171,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers subagentManager := tools.NewSubagentManager(provider, workspace, msgBus, orchestrator) spawnTool := tools.NewSpawnTool(subagentManager) toolsRegistry.Register(spawnTool) - toolsRegistry.Register(tools.NewSubagentsTool(subagentManager, cfg.Agents.Defaults.Texts.SubagentsNone, cfg.Agents.Defaults.Texts.UnsupportedAction)) + toolsRegistry.Register(tools.NewSubagentsTool(subagentManager)) toolsRegistry.Register(tools.NewSessionsTool( func(limit int) []tools.SessionInfo { sessions := alSessionListForTool(sessionsManager, limit) @@ -194,8 +184,6 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } return h }, - cfg.Agents.Defaults.Texts.SessionsNone, - cfg.Agents.Defaults.Texts.UnsupportedAction, )) // Register edit file tool @@ -220,36 +208,27 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry.Register(tools.NewSystemInfoTool()) loop := &AgentLoop{ - bus: msgBus, - provider: provider, - workspace: workspace, - model: provider.GetDefaultModel(), - maxIterations: cfg.Agents.Defaults.MaxToolIterations, - sessions: sessionsManager, - contextBuilder: NewContextBuilder(workspace, func() []string { return toolsRegistry.GetSummaries() }), - tools: toolsRegistry, - compactionEnabled: cfg.Agents.Defaults.ContextCompaction.Enabled, - compactionTrigger: cfg.Agents.Defaults.ContextCompaction.TriggerMessages, - compactionKeepRecent: cfg.Agents.Defaults.ContextCompaction.KeepRecentMessages, - heartbeatAckMaxChars: cfg.Agents.Defaults.Heartbeat.AckMaxChars, - memoryRecallKeywords: cfg.Agents.Defaults.Texts.MemoryRecallKeywords, - noResponseFallback: cfg.Agents.Defaults.Texts.NoResponseFallback, - thinkOnlyFallback: cfg.Agents.Defaults.Texts.ThinkOnlyFallback, - langUsage: cfg.Agents.Defaults.Texts.LangUsage, - langInvalid: cfg.Agents.Defaults.Texts.LangInvalid, - langUpdatedTemplate: cfg.Agents.Defaults.Texts.LangUpdatedTemplate, - runtimeCompactionNote: cfg.Agents.Defaults.Texts.RuntimeCompactionNote, - startupCompactionNote: cfg.Agents.Defaults.Texts.StartupCompactionNote, - systemRewriteTemplate: cfg.Agents.Defaults.Texts.SystemRewriteTemplate, - audit: newTriggerAudit(workspace), - running: false, - intentHints: map[string]string{}, - sessionScheduler: NewSessionScheduler(0), - ekg: ekg.New(workspace), - sessionProvider: map[string]string{}, - sessionStreamed: map[string]bool{}, - providerResponses: map[string]config.ProviderResponsesConfig{}, - telegramStreaming: cfg.Channels.Telegram.Streaming, + bus: msgBus, + provider: provider, + workspace: workspace, + model: provider.GetDefaultModel(), + maxIterations: cfg.Agents.Defaults.MaxToolIterations, + sessions: sessionsManager, + contextBuilder: NewContextBuilder(workspace, func() []string { return toolsRegistry.GetSummaries() }), + tools: toolsRegistry, + compactionEnabled: cfg.Agents.Defaults.ContextCompaction.Enabled, + compactionTrigger: cfg.Agents.Defaults.ContextCompaction.TriggerMessages, + compactionKeepRecent: cfg.Agents.Defaults.ContextCompaction.KeepRecentMessages, + heartbeatAckMaxChars: cfg.Agents.Defaults.Heartbeat.AckMaxChars, + heartbeatAckToken: loadHeartbeatAckToken(workspace), + audit: newTriggerAudit(workspace), + running: false, + sessionScheduler: NewSessionScheduler(0), + ekg: ekg.New(workspace), + sessionProvider: map[string]string{}, + sessionStreamed: map[string]bool{}, + providerResponses: map[string]config.ProviderResponsesConfig{}, + telegramStreaming: cfg.Channels.Telegram.Streaming, } // Initialize provider fallback chain (primary + proxy_fallbacks). loop.providerPool = map[string]providers.LLMProvider{} @@ -560,22 +539,27 @@ func (al *AgentLoop) appendTaskAuditEvent(taskID string, msg bus.InboundMessage, } func sessionShardCount() int { - if v := strings.TrimSpace(os.Getenv("CLAWGO_SESSION_SHARDS")); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - if n > 64 { - return 64 - } - return n - } + // Keep ~20% CPU headroom for system/background work, then use a + // sub-linear curve to avoid oversharding on high-core machines. + n := runtime.GOMAXPROCS(0) + if n <= 0 { + n = runtime.NumCPU() } - n := runtime.NumCPU() - if n < 2 { - n = 2 + if n <= 0 { + return 2 } - if n > 16 { - n = 16 + budget := int(math.Floor(float64(n) * 0.8)) + if budget < 1 { + budget = 1 } - return n + shards := int(math.Round(math.Sqrt(float64(budget)) * 2.2)) + if shards < 2 { + shards = 2 + } + if shards > 12 { + shards = 12 + } + return shards } func sessionShardIndex(sessionKey string, shardCount int) int { @@ -617,7 +601,11 @@ func (al *AgentLoop) shouldSuppressOutbound(msg bus.InboundMessage, response str } r := strings.TrimSpace(response) - if !strings.HasPrefix(r, "HEARTBEAT_OK") { + ackToken := strings.TrimSpace(al.heartbeatAckToken) + if ackToken == "" { + return false + } + if !strings.HasPrefix(r, ackToken) { return false } @@ -628,6 +616,43 @@ func (al *AgentLoop) shouldSuppressOutbound(msg bus.InboundMessage, response str return len(r) <= maxChars } +func loadHeartbeatAckToken(workspace string) string { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return "" + } + parse := func(text string) string { + for _, line := range strings.Split(text, "\n") { + t := strings.TrimSpace(line) + if t == "" { + continue + } + raw := strings.TrimLeft(t, "-*# ") + lower := strings.ToLower(raw) + if !strings.HasPrefix(lower, "heartbeat_ack_token:") { + continue + } + v := strings.TrimSpace(raw[len("heartbeat_ack_token:"):]) + v = strings.Trim(v, "`\"' ") + if v != "" { + return v + } + } + return "" + } + if b, err := os.ReadFile(filepath.Join(workspace, "AGENTS.md")); err == nil { + if token := parse(string(b)); token != "" { + return token + } + } + if b, err := os.ReadFile(filepath.Join(workspace, "HEARTBEAT.md")); err == nil { + if token := parse(string(b)); token != "" { + return token + } + } + return "" +} + func (al *AgentLoop) prepareOutbound(msg bus.InboundMessage, response string) (bus.OutboundMessage, bool) { if shouldDropNoReply(response) { return bus.OutboundMessage{}, false @@ -700,64 +725,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.processSystemMessage(ctx, msg) } - // Explicit language command: /lang - if strings.HasPrefix(msg.Content, "/lang") { - parts := strings.Fields(msg.Content) - if len(parts) < 2 { - preferred, last := al.sessions.GetLanguagePreferences(msg.SessionKey) - if preferred == "" { - preferred = "(auto)" - } - if last == "" { - last = "(none)" - } - usage := strings.TrimSpace(al.langUsage) - if usage == "" { - usage = "Usage: /lang " - } - return fmt.Sprintf("%s\nCurrent preferred: %s\nLast detected: %s", usage, preferred, last), nil - } - lang := normalizeLang(parts[1]) - if lang == "" { - invalid := strings.TrimSpace(al.langInvalid) - if invalid == "" { - invalid = "Invalid language code." - } - return invalid, nil - } - al.sessions.SetPreferredLanguage(msg.SessionKey, lang) - al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey)) - tpl := strings.TrimSpace(al.langUpdatedTemplate) - if tpl == "" { - tpl = "Language preference updated to %s" - } - return fmt.Sprintf(tpl, lang), nil - } - history := al.sessions.GetHistory(msg.SessionKey) summary := al.sessions.GetSummary(msg.SessionKey) - memoryRecallUsed := false - memoryRecallText := "" - if shouldRecallMemory(msg.Content, al.memoryRecallKeywords) { - if recall, err := al.tools.Execute(ctx, "memory_search", map[string]interface{}{"query": msg.Content, "maxResults": 3}); err == nil && strings.TrimSpace(recall) != "" { - memoryRecallUsed = true - memoryRecallText = strings.TrimSpace(recall) - summary = strings.TrimSpace(summary + "\n\n[Memory Recall]\n" + memoryRecallText) - } - } if explicitPref := ExtractLanguagePreference(msg.Content); explicitPref != "" { al.sessions.SetPreferredLanguage(msg.SessionKey, explicitPref) } preferredLang, lastLang := al.sessions.GetLanguagePreferences(msg.SessionKey) responseLang := DetectResponseLanguage(msg.Content, preferredLang, lastLang) - al.updateIntentHint(msg.SessionKey, msg.Content) - effectiveUserContent := al.applyIntentHint(msg.SessionKey, msg.Content) - messages := al.contextBuilder.BuildMessages( history, summary, - effectiveUserContent, + msg.Content, nil, msg.Channel, msg.ChatID, @@ -772,15 +751,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) if maxAllowed < 1 { maxAllowed = 1 } - // CLAWGO_MAX_TOOL_ITERATIONS: - // 0 or unset => no fixed cap, keep extending while tool chain progresses - // >0 => explicit ceiling - hardCap := 0 - if v := os.Getenv("CLAWGO_MAX_TOOL_ITERATIONS"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n >= 0 { - hardCap = n - } - } for iteration < maxAllowed { iteration++ @@ -910,15 +880,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) al.sessions.AddMessageFull(msg.SessionKey, assistantMsg) hasToolActivity = true - if hardCap > 0 { - if maxAllowed < hardCap { - maxAllowed = hardCap - } - } else { - // No fixed cap: extend rolling window as long as tools keep chaining. - if maxAllowed < iteration+al.maxIterations { - maxAllowed = iteration + al.maxIterations - } + // Extend rolling window as long as tools keep chaining. + if maxAllowed < iteration+al.maxIterations { + maxAllowed = iteration + al.maxIterations } for _, tc := range response.ToolCalls { // Log tool call with arguments preview @@ -932,7 +896,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }) execArgs := withToolContextArgs(tc.Name, tc.Arguments, msg.Channel, msg.ChatID) - result, err := al.tools.Execute(ctx, tc.Name, execArgs) + result, err := al.executeToolCall(ctx, tc.Name, execArgs, msg.Channel, msg.ChatID) if err != nil { result = fmt.Sprintf("Error: %v", err) } @@ -956,17 +920,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) finalContent = forced.Content } } - if finalContent == "" { - if hasToolActivity && len(lastToolOutputs) > 0 { - finalContent = "我已执行完成,关键信息如下:\n- " + strings.Join(lastToolOutputs, "\n- ") - } else { - fallback := strings.TrimSpace(al.noResponseFallback) - if fallback == "" { - fallback = "在的,我刚刚这条回复丢了。请再说一次,我马上处理。" - } - finalContent = fallback - } - } // Filter out ... content from user-facing response // Keep full content in debug logs if needed, but remove from final output @@ -977,19 +930,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) // For now, let's assume thoughts are auxiliary and empty response is okay if tools did work. // If no tools ran and only thoughts, user might be confused. if iteration == 1 { - fallback := strings.TrimSpace(al.thinkOnlyFallback) - if fallback == "" { - fallback = "Thinking process completed." - } - userContent = fallback + userContent = "Thinking process completed." } } - if memoryRecallUsed && !strings.Contains(strings.ToLower(userContent), "source:") { - if src := extractFirstSourceLine(memoryRecallText); src != "" { - userContent = strings.TrimSpace(userContent + "\n\n" + src) - } - } al.sessions.AddMessage(msg.SessionKey, "user", msg.Content) // Persist full assistant response (including reasoning/tool flow outcomes when present). @@ -1075,55 +1019,6 @@ func (al *AgentLoop) appendDailySummaryLog(msg bus.InboundMessage, response stri } } -func (al *AgentLoop) updateIntentHint(sessionKey, content string) { - content = strings.TrimSpace(content) - if sessionKey == "" || content == "" { - return - } - lower := strings.ToLower(content) - - // Cron natural-language intent: avoid searching project files for user timer ops. - if strings.Contains(lower, "cron") || strings.Contains(lower, "schedule") || strings.Contains(lower, "timer") || strings.Contains(lower, "reminder") { - hint := "Prioritize the cron tool for timer operations: list=action=list; delete=action=delete(id); enable/disable=action=enable/disable. Do not switch to grepping project files for cron text." - al.intentMu.Lock() - al.intentHints[sessionKey] = hint + " User details=" + content - al.intentMu.Unlock() - return - } - - if !strings.Contains(lower, "commit") && !strings.Contains(lower, "push") { - if strings.HasPrefix(content, "1.") || strings.HasPrefix(content, "2.") { - al.intentMu.Lock() - if prev := strings.TrimSpace(al.intentHints[sessionKey]); prev != "" { - al.intentHints[sessionKey] = prev + " | " + content - } - al.intentMu.Unlock() - } - return - } - hint := "Execute as one transaction: complete commit+push in one pass with branch/scope confirmation." - if strings.Contains(lower, "all branches") { - hint += " Scope=all branches." - } - al.intentMu.Lock() - al.intentHints[sessionKey] = hint + " User details=" + content - al.intentMu.Unlock() -} - -func (al *AgentLoop) applyIntentHint(sessionKey, content string) string { - al.intentMu.RLock() - hint := strings.TrimSpace(al.intentHints[sessionKey]) - al.intentMu.RUnlock() - if hint == "" { - return content - } - lower := strings.ToLower(strings.TrimSpace(content)) - if strings.Contains(lower, "commit") || strings.Contains(lower, "push") || strings.HasPrefix(content, "1.") || strings.HasPrefix(content, "2.") || strings.Contains(lower, "cron") || strings.Contains(lower, "schedule") || strings.Contains(lower, "timer") || strings.Contains(lower, "reminder") { - return "[Intent Slot]\n" + hint + "\n\n[User Message]\n" + content - } - return content -} - func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { // Verify this is a system message if msg.Channel != "system" { @@ -1136,8 +1031,6 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe "chat_id": msg.ChatID, }) - msg.Content = rewriteSystemMessageContent(msg.Content, al.systemRewriteTemplate) - // Parse origin from chat_id (format: "channel:chat_id") var originChannel, originChatID string if idx := strings.Index(msg.ChatID, ":"); idx > 0 { @@ -1246,7 +1139,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe for _, tc := range response.ToolCalls { execArgs := withToolContextArgs(tc.Name, tc.Arguments, originChannel, originChatID) - result, err := al.tools.Execute(ctx, tc.Name, execArgs) + result, err := al.executeToolCall(ctx, tc.Name, execArgs, originChannel, originChatID) if err != nil { result = fmt.Sprintf("Error: %v", err) } @@ -1496,13 +1389,10 @@ func (al *AgentLoop) compactSessionIfNeeded(sessionKey string) { return } removed := len(h) - keepRecent - tpl := strings.TrimSpace(al.runtimeCompactionNote) - if tpl == "" { - tpl = "[runtime-compaction] removed %d old messages, kept %d recent messages" - } + tpl := "[runtime-compaction] removed %d old messages, kept %d recent messages" note := fmt.Sprintf(tpl, removed, keepRecent) if al.sessions.CompactSession(sessionKey, keepRecent, note) { - al.sessions.Save(al.sessions.GetOrCreate(sessionKey)) + _ = al.sessions.Save(al.sessions.GetOrCreate(sessionKey)) } } @@ -1538,10 +1428,7 @@ func (al *AgentLoop) RunStartupSelfCheckAllSessions(ctx context.Context) Startup } removed := len(history) - keepRecent - tpl := strings.TrimSpace(al.startupCompactionNote) - if tpl == "" { - tpl = "[startup-compaction] removed %d old messages, kept %d recent messages" - } + tpl := "[startup-compaction] removed %d old messages, kept %d recent messages" note := fmt.Sprintf(tpl, removed, keepRecent) if al.sessions.CompactSession(key, keepRecent, note) { al.sessions.Save(al.sessions.GetOrCreate(key)) @@ -1556,10 +1443,10 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { info := make(map[string]interface{}) // Tools info - tools := al.tools.List() + _tools := al.tools.List() info["tools"] = map[string]interface{}{ - "count": len(tools), - "names": tools, + "count": len(_tools), + "names": _tools, } // Skills info @@ -1666,21 +1553,46 @@ func withToolContextArgs(toolName string, args map[string]interface{}, channel, return next } -func shouldRecallMemory(text string, keywords []string) bool { - s := strings.ToLower(strings.TrimSpace(text)) - if s == "" { +func (al *AgentLoop) executeToolCall(ctx context.Context, toolName string, args map[string]interface{}, currentChannel, currentChatID string) (string, error) { + if shouldSuppressSelfMessageSend(toolName, args, currentChannel, currentChatID) { + return "Suppressed message tool self-send in current chat; assistant will reply via normal outbound.", nil + } + return al.tools.Execute(ctx, toolName, args) +} + +func shouldSuppressSelfMessageSend(toolName string, args map[string]interface{}, currentChannel, currentChatID string) bool { + if strings.TrimSpace(toolName) != "message" { return false } - if len(keywords) == 0 { - keywords = []string{"remember", "preference", "todo", "decision", "date", "when did", "what did we"} + action, _ := args["action"].(string) + action = strings.ToLower(strings.TrimSpace(action)) + if action == "" { + action = "send" } - for _, k := range keywords { - kk := strings.ToLower(strings.TrimSpace(k)) - if kk != "" && strings.Contains(s, kk) { - return true - } + if action != "send" { + return false } - return false + + targetChannel, targetChat := resolveMessageToolTarget(args, currentChannel, currentChatID) + return targetChannel == strings.TrimSpace(currentChannel) && targetChat == strings.TrimSpace(currentChatID) +} + +func resolveMessageToolTarget(args map[string]interface{}, fallbackChannel, fallbackChatID string) (string, string) { + channel, _ := args["channel"].(string) + channel = strings.TrimSpace(channel) + if channel == "" { + channel = strings.TrimSpace(fallbackChannel) + } + + chatID, _ := args["chat_id"].(string) + if to, _ := args["to"].(string); strings.TrimSpace(to) != "" { + chatID = to + } + chatID = strings.TrimSpace(chatID) + if chatID == "" { + chatID = strings.TrimSpace(fallbackChatID) + } + return channel, chatID } func extractFirstSourceLine(text string) string { @@ -1747,25 +1659,6 @@ func parseReplyTag(text string, currentMessageID string) (content string, replyT return text, "" } -func rewriteSystemMessageContent(content, template string) string { - c := strings.TrimSpace(content) - if !strings.HasPrefix(c, "[System Message]") { - return content - } - body := strings.TrimSpace(strings.TrimPrefix(c, "[System Message]")) - if body == "" { - return "Please summarize the system event in concise user-facing language." - } - tpl := strings.TrimSpace(template) - if tpl == "" { - tpl = "Rewrite the following internal system update in concise user-facing language:\n\n%s" - } - if strings.Contains(tpl, "%s") { - return fmt.Sprintf(tpl, body) - } - return tpl + "\n\n" + body -} - func alSessionListForTool(sm *session.SessionManager, limit int) []tools.SessionInfo { items := sm.List(limit) out := make([]tools.SessionInfo, 0, len(items)) diff --git a/pkg/agent/loop_responses_options_test.go b/pkg/agent/loop_responses_options_test.go deleted file mode 100644 index b206f80..0000000 --- a/pkg/agent/loop_responses_options_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package agent - -import ( - "testing" - - "clawgo/pkg/bus" - "clawgo/pkg/config" - "clawgo/pkg/providers" -) - -func TestInjectResponsesMediaParts(t *testing.T) { - msgs := []providers.Message{ - {Role: "system", Content: "sys"}, - {Role: "user", Content: "look"}, - } - media := []string{"https://example.com/a.png"} - items := []bus.MediaItem{ - {Type: "image", Ref: "file_img_1"}, - {Type: "document", Ref: "file_doc_1"}, - } - - got := injectResponsesMediaParts(msgs, media, items) - if len(got) != 2 { - t.Fatalf("unexpected messages length: %#v", got) - } - parts := got[1].ContentParts - if len(parts) != 3 { - t.Fatalf("expected 3 content parts, got %#v", parts) - } - if parts[0].Type != "input_text" || parts[0].Text != "look" { - t.Fatalf("expected first part to preserve input text, got %#v", parts[0]) - } - if parts[1].Type != "input_image" || parts[1].FileID != "file_img_1" { - t.Fatalf("expected image media item mapped to file_id, got %#v", parts[1]) - } - if parts[2].Type != "input_file" || parts[2].FileID != "file_doc_1" { - t.Fatalf("expected document media item mapped to input_file file_id, got %#v", parts[2]) - } -} - -func TestBuildResponsesOptionsFromConfig(t *testing.T) { - al := &AgentLoop{ - providerNames: []string{"proxy"}, - providerResponses: map[string]config.ProviderResponsesConfig{ - "proxy": { - WebSearchEnabled: true, - WebSearchContextSize: "high", - FileSearchVectorStoreIDs: []string{"vs_1", "vs_2"}, - FileSearchMaxNumResults: 8, - Include: []string{"output_text", "tool_calls"}, - StreamIncludeUsage: true, - }, - }, - } - - opts := al.buildResponsesOptions("session-a", 1024, 0.2) - if opts["max_tokens"] != int64(1024) { - t.Fatalf("max_tokens mismatch: %#v", opts["max_tokens"]) - } - if opts["temperature"] != 0.2 { - t.Fatalf("temperature mismatch: %#v", opts["temperature"]) - } - toolsRaw, ok := opts["responses_tools"].([]map[string]interface{}) - if !ok || len(toolsRaw) != 2 { - t.Fatalf("expected two built-in response tools, got %#v", opts["responses_tools"]) - } - if toolsRaw[0]["type"] != "web_search" { - t.Fatalf("expected web_search tool first, got %#v", toolsRaw[0]) - } - if toolsRaw[1]["type"] != "file_search" { - t.Fatalf("expected file_search tool second, got %#v", toolsRaw[1]) - } - if _, ok := opts["responses_include"]; !ok { - t.Fatalf("expected responses_include in options") - } - if _, ok := opts["responses_stream_options"]; !ok { - t.Fatalf("expected responses_stream_options in options") - } -} - -func TestInjectResponsesMediaParts_SkipsLocalPathsForResponsesContentParts(t *testing.T) { - msgs := []providers.Message{ - {Role: "user", Content: "check local media"}, - } - items := []bus.MediaItem{ - {Type: "image", Path: "/tmp/a.png"}, - {Type: "document", Path: "/tmp/a.pdf"}, - } - got := injectResponsesMediaParts(msgs, nil, items) - if len(got[0].ContentParts) != 1 { - t.Fatalf("expected only input_text for local files, got %#v", got[0].ContentParts) - } - if got[0].ContentParts[0].Type != "input_text" { - t.Fatalf("expected input_text only, got %#v", got[0].ContentParts[0]) - } -} diff --git a/pkg/agent/loop_session_regression_test.go b/pkg/agent/loop_session_regression_test.go deleted file mode 100644 index 294ef27..0000000 --- a/pkg/agent/loop_session_regression_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package agent - -import ( - "context" - "fmt" - "path/filepath" - "sync" - "testing" - "time" - - "clawgo/pkg/bus" - "clawgo/pkg/config" - "clawgo/pkg/providers" -) - -type recordingProvider struct { - mu sync.Mutex - calls [][]providers.Message - responses []providers.LLMResponse -} - -func (p *recordingProvider) Chat(_ context.Context, messages []providers.Message, _ []providers.ToolDefinition, _ string, _ map[string]interface{}) (*providers.LLMResponse, error) { - p.mu.Lock() - defer p.mu.Unlock() - cp := make([]providers.Message, len(messages)) - copy(cp, messages) - p.calls = append(p.calls, cp) - if len(p.responses) == 0 { - resp := providers.LLMResponse{Content: "ok", FinishReason: "stop"} - return &resp, nil - } - resp := p.responses[0] - p.responses = p.responses[1:] - return &resp, nil -} - -func (p *recordingProvider) GetDefaultModel() string { return "test-model" } - -func setupLoop(t *testing.T, rp *recordingProvider) *AgentLoop { - t.Helper() - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - cfg.Agents.Defaults.MaxToolIterations = 2 - cfg.Agents.Defaults.ContextCompaction.Enabled = false - return NewAgentLoop(cfg, bus.NewMessageBus(), rp, nil) -} - -func lastUserContent(msgs []providers.Message) string { - for i := len(msgs) - 1; i >= 0; i-- { - if msgs[i].Role == "user" { - return msgs[i].Content - } - } - return "" -} - -func containsUserContent(msgs []providers.Message, needle string) bool { - for _, m := range msgs { - if m.Role == "user" && m.Content == needle { - return true - } - } - return false -} - -func TestProcessDirect_UsesCallerSessionKey(t *testing.T) { - rp := &recordingProvider{} - loop := setupLoop(t, rp) - - if _, err := loop.ProcessDirect(context.Background(), "from-session-a", "session-a"); err != nil { - t.Fatalf("ProcessDirect session-a failed: %v", err) - } - if _, err := loop.ProcessDirect(context.Background(), "from-session-b", "session-b"); err != nil { - t.Fatalf("ProcessDirect session-b failed: %v", err) - } - - if len(rp.calls) != 2 { - t.Fatalf("expected 2 provider calls, got %d", len(rp.calls)) - } - second := rp.calls[1] - if got := lastUserContent(second); got != "from-session-b" { - t.Fatalf("unexpected last user content in second call: %q", got) - } - if containsUserContent(second, "from-session-a") { - t.Fatalf("session-a message leaked into session-b history") - } -} - -func TestProcessSystemMessage_UsesOriginSessionKey(t *testing.T) { - rp := &recordingProvider{} - loop := setupLoop(t, rp) - - sys := bus.InboundMessage{Channel: "system", SenderID: "cron", ChatID: "telegram:chat-1", Content: "system task"} - if _, err := loop.processMessage(context.Background(), sys); err != nil { - t.Fatalf("processMessage(system) failed: %v", err) - } - if _, err := loop.ProcessDirect(context.Background(), "follow-up", "telegram:chat-1"); err != nil { - t.Fatalf("ProcessDirect follow-up failed: %v", err) - } - - if len(rp.calls) != 2 { - t.Fatalf("expected 2 provider calls, got %d", len(rp.calls)) - } - second := rp.calls[1] - want := "[System: cron] " + rewriteSystemMessageContent("system task", loop.systemRewriteTemplate) - if !containsUserContent(second, want) { - t.Fatalf("expected system marker in follow-up history, want=%q got=%v", want, summarizeUsers(second)) - } -} - -func TestProcessInbound_SystemMessagePublishesToOriginChannel(t *testing.T) { - rp := &recordingProvider{} - loop := setupLoop(t, rp) - - in := bus.InboundMessage{Channel: "system", SenderID: "cron", ChatID: "telegram:chat-1", Content: "system task"} - loop.processInbound(context.Background(), in) - - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - out, ok := loop.bus.SubscribeOutbound(ctx) - if !ok { - t.Fatalf("expected outbound message") - } - if out.Channel != "telegram" { - t.Fatalf("expected outbound channel telegram, got %q", out.Channel) - } - if out.ChatID != "chat-1" { - t.Fatalf("expected outbound chat_id chat-1, got %q", out.ChatID) - } -} - -func summarizeUsers(msgs []providers.Message) []string { - out := []string{} - for _, m := range msgs { - if m.Role == "user" { - out = append(out, fmt.Sprintf("%q", m.Content)) - } - } - return out -} - -func TestResolveMessageResourceKeys_FromMetadata(t *testing.T) { - loop := &AgentLoop{} - msg := &bus.InboundMessage{ - Content: "do task", - Metadata: map[string]string{ - "resource_keys": "repo:acme/app,file:pkg/a.go", - }, - } - keys, cleaned := loop.resolveMessageResourceKeys(msg) - if cleaned != "do task" { - t.Fatalf("unexpected cleaned content: %q", cleaned) - } - if len(keys) != 2 { - t.Fatalf("unexpected keys: %#v", keys) - } -} - -func TestResolveMessageResourceKeys_FromContentDirective(t *testing.T) { - loop := &AgentLoop{} - msg := &bus.InboundMessage{ - Content: "[resource_keys: repo:acme/app,file:pkg/a.go]\nplease fix", - } - keys, cleaned := loop.resolveMessageResourceKeys(msg) - if len(keys) != 2 { - t.Fatalf("unexpected keys: %#v", keys) - } - if cleaned != "please fix" { - t.Fatalf("unexpected cleaned content: %q", cleaned) - } -} diff --git a/pkg/agent/loop_tool_context_test.go b/pkg/agent/loop_tool_context_test.go deleted file mode 100644 index 8fdf859..0000000 --- a/pkg/agent/loop_tool_context_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package agent - -import "testing" - -func TestWithToolContextArgsInjectsDefaults(t *testing.T) { - args := map[string]interface{}{"message": "hello"} - got := withToolContextArgs("message", args, "telegram", "chat-1") - if got["channel"] != "telegram" { - t.Fatalf("expected channel injected, got %v", got["channel"]) - } - if got["chat_id"] != "chat-1" { - t.Fatalf("expected chat_id injected, got %v", got["chat_id"]) - } -} - -func TestWithToolContextArgsPreservesExplicitTarget(t *testing.T) { - args := map[string]interface{}{"message": "hello", "to": "target-2"} - got := withToolContextArgs("message", args, "telegram", "chat-1") - if _, ok := got["chat_id"]; ok { - t.Fatalf("chat_id should not be injected when 'to' is provided") - } - if got["to"] != "target-2" { - t.Fatalf("expected to preserved, got %v", got["to"]) - } -} - -func TestWithToolContextArgsSkipsUnrelatedTools(t *testing.T) { - args := map[string]interface{}{"query": "x"} - got := withToolContextArgs("memory_search", args, "telegram", "chat-1") - if len(got) != len(args) { - t.Fatalf("expected unchanged args for unrelated tool") - } - if _, ok := got["channel"]; ok { - t.Fatalf("unexpected channel key for unrelated tool") - } -} diff --git a/pkg/agent/session_planner_test.go b/pkg/agent/session_planner_test.go deleted file mode 100644 index f6d2d36..0000000 --- a/pkg/agent/session_planner_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package agent - -import ( - "context" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - "time" - - "clawgo/pkg/bus" - "clawgo/pkg/config" - "clawgo/pkg/ekg" - "clawgo/pkg/providers" -) - -func TestSplitPlannedSegments_Bullets(t *testing.T) { - parts := splitPlannedSegments("- 修复 a.go\n- 补充 b.go 测试") - if len(parts) != 2 { - t.Fatalf("unexpected parts: %#v", parts) - } -} - -func TestPlanSessionTasks_Semicolon(t *testing.T) { - loop := &AgentLoop{} - tasks := loop.planSessionTasks(bus.InboundMessage{Channel: "cli", Content: "修复 pkg/a.go;修复 pkg/b.go"}) - if len(tasks) != 2 { - t.Fatalf("expected 2 tasks, got %#v", tasks) - } - if tasks[0].Content == tasks[1].Content { - t.Fatalf("expected distinct tasks: %#v", tasks) - } -} - -func TestProcessPlannedMessage_AggregatesResults(t *testing.T) { - rp := &recordingProvider{responses: []providers.LLMResponse{ - {Content: "done-a", FinishReason: "stop"}, - {Content: "done-b", FinishReason: "stop"}, - }} - loop := setupLoop(t, rp) - - resp, err := loop.processPlannedMessage(context.Background(), bus.InboundMessage{ - Channel: "cli", - SenderID: "u", - ChatID: "direct", - SessionKey: "sess-plan", - Content: "修复 pkg/a.go;补充 pkg/b.go 测试", - }) - if err != nil { - t.Fatalf("processPlannedMessage error: %v", err) - } - if len(rp.calls) != 2 { - t.Fatalf("expected 2 provider calls, got %d", len(rp.calls)) - } - if resp == "" { - t.Fatalf("expected aggregate response") - } -} - -type probeProvider struct { - mu sync.Mutex - inFlight int - maxInFlight int - delayPerCall time.Duration - responseCount int -} - -func (p *probeProvider) Chat(_ context.Context, _ []providers.Message, _ []providers.ToolDefinition, _ string, _ map[string]interface{}) (*providers.LLMResponse, error) { - p.mu.Lock() - p.inFlight++ - if p.inFlight > p.maxInFlight { - p.maxInFlight = p.inFlight - } - p.responseCount++ - p.mu.Unlock() - - time.Sleep(p.delayPerCall) - - p.mu.Lock() - n := p.responseCount - p.inFlight-- - p.mu.Unlock() - resp := providers.LLMResponse{Content: "done-" + strconv.Itoa(n), FinishReason: "stop"} - return &resp, nil -} - -func (p *probeProvider) GetDefaultModel() string { return "test-model" } - -func TestRunPlannedTasks_NonConflictingKeysCanRunInParallel(t *testing.T) { - p := &probeProvider{delayPerCall: 100 * time.Millisecond} - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - cfg.Agents.Defaults.MaxToolIterations = 2 - cfg.Agents.Defaults.ContextCompaction.Enabled = false - loop := NewAgentLoop(cfg, bus.NewMessageBus(), p, nil) - - _, err := loop.processPlannedMessage(context.Background(), bus.InboundMessage{ - Channel: "cli", - SenderID: "u", - ChatID: "direct", - SessionKey: "sess-plan-parallel", - Content: "[resource_keys: file:pkg/a.go] 修复 a;[resource_keys: file:pkg/b.go] 修复 b", - }) - if err != nil { - t.Fatalf("processPlannedMessage error: %v", err) - } - if p.maxInFlight < 2 { - t.Fatalf("expected parallel execution for non-conflicting keys, got maxInFlight=%d", p.maxInFlight) - } -} - -func TestRunPlannedTasks_ConflictingKeysMutuallyExclusive(t *testing.T) { - p := &probeProvider{delayPerCall: 100 * time.Millisecond} - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - cfg.Agents.Defaults.MaxToolIterations = 2 - cfg.Agents.Defaults.ContextCompaction.Enabled = false - loop := NewAgentLoop(cfg, bus.NewMessageBus(), p, nil) - - _, err := loop.processPlannedMessage(context.Background(), bus.InboundMessage{ - Channel: "cli", - SenderID: "u", - ChatID: "direct", - SessionKey: "sess-plan-locked", - Content: "[resource_keys: file:pkg/a.go] 修复 a;[resource_keys: file:pkg/a.go] 补测试", - }) - if err != nil { - t.Fatalf("processPlannedMessage error: %v", err) - } - if p.maxInFlight != 1 { - t.Fatalf("expected mutual exclusion for conflicting keys, got maxInFlight=%d", p.maxInFlight) - } -} - -func TestRunPlannedTasks_PublishesStepProgress(t *testing.T) { - rp := &recordingProvider{responses: []providers.LLMResponse{ - {Content: "done-a", FinishReason: "stop"}, - {Content: "done-b", FinishReason: "stop"}, - }} - loop := setupLoop(t, rp) - - _, err := loop.processPlannedMessage(context.Background(), bus.InboundMessage{ - Channel: "cli", - SenderID: "u", - ChatID: "direct", - SessionKey: "sess-plan-progress", - Content: "修复 pkg/a.go;补充 pkg/b.go 测试", - }) - if err != nil { - t.Fatalf("processPlannedMessage error: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - out1, ok := loop.bus.SubscribeOutbound(ctx) - if !ok { - t.Fatalf("expected first progress outbound") - } - out2, ok := loop.bus.SubscribeOutbound(ctx) - if !ok { - t.Fatalf("expected second progress outbound") - } - all := out1.Content + "\n" + out2.Content - if !strings.Contains(all, "进度 1/2") || !strings.Contains(all, "进度 2/2") { - t.Fatalf("unexpected progress outputs:\n%s", all) - } -} - -func TestFindRecentRelatedErrorEvent(t *testing.T) { - ws := filepath.Join(t.TempDir(), "workspace") - _ = os.MkdirAll(filepath.Join(ws, "memory"), 0o755) - line := `{"task_id":"t1","status":"error","log":"open /tmp/a.go failed","input_preview":"修复 pkg/a.go 的读取错误","source":"direct","channel":"cli"}` - if err := os.WriteFile(filepath.Join(ws, "memory", "task-audit.jsonl"), []byte(line+"\n"), 0o644); err != nil { - t.Fatalf("write audit: %v", err) - } - loop := &AgentLoop{workspace: ws, ekg: ekg.New(ws)} - loop.ekg.Record(ekg.Event{TaskID: "t1", Status: "error", Log: "open /tmp/a.go failed"}) - loop.ekg.Record(ekg.Event{TaskID: "t1", Status: "error", Log: "open /tmp/a.go failed"}) - loop.ekg.Record(ekg.Event{TaskID: "t1", Status: "error", Log: "open /tmp/a.go failed"}) - - ev, ok := loop.findRecentRelatedErrorEvent("请修复 pkg/a.go 的读取问题") - if !ok { - t.Fatalf("expected matched recent error event") - } - if ev.TaskID != "t1" { - t.Fatalf("unexpected task id: %s", ev.TaskID) - } - if hint := loop.ekgHintForTask(plannedTask{Content: "修复 pkg/a.go"}); hint == "" { - t.Fatalf("expected non-empty ekg hint") - } -} diff --git a/pkg/agent/session_scheduler_test.go b/pkg/agent/session_scheduler_test.go deleted file mode 100644 index acb7a22..0000000 --- a/pkg/agent/session_scheduler_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package agent - -import ( - "context" - "testing" - "time" -) - -func TestSessionSchedulerConflictSerializes(t *testing.T) { - s := NewSessionScheduler(4) - release1, err := s.Acquire(context.Background(), "sess-a", []string{"file:a.go"}) - if err != nil { - t.Fatalf("acquire first: %v", err) - } - defer release1() - - acquired2 := make(chan struct{}, 1) - done2 := make(chan struct{}, 1) - go func() { - defer func() { done2 <- struct{}{} }() - release2, err := s.Acquire(context.Background(), "sess-a", []string{"file:a.go"}) - if err != nil { - return - } - acquired2 <- struct{}{} - release2() - }() - - select { - case <-acquired2: - t.Fatalf("second conflicting run should wait") - case <-time.After(80 * time.Millisecond): - } - - release1() - - select { - case <-acquired2: - case <-time.After(time.Second): - t.Fatalf("second run should acquire after release") - } - <-done2 -} - -func TestSessionSchedulerNonConflictingCanRunInParallel(t *testing.T) { - s := NewSessionScheduler(4) - release1, err := s.Acquire(context.Background(), "sess-a", []string{"file:a.go"}) - if err != nil { - t.Fatalf("acquire first: %v", err) - } - defer release1() - - acquired2 := make(chan struct{}, 1) - done2 := make(chan struct{}, 1) - go func() { - defer func() { done2 <- struct{}{} }() - release2, err := s.Acquire(context.Background(), "sess-a", []string{"file:b.go"}) - if err != nil { - return - } - acquired2 <- struct{}{} - release2() - }() - - select { - case <-acquired2: - case <-time.After(time.Second): - t.Fatalf("second non-conflicting run should acquire immediately") - } - <-done2 -} - -func TestSessionSchedulerHonorsSessionMaxParallel(t *testing.T) { - s := NewSessionScheduler(1) - release1, err := s.Acquire(context.Background(), "sess-a", []string{"file:a.go"}) - if err != nil { - t.Fatalf("acquire first: %v", err) - } - defer release1() - - acquired2 := make(chan struct{}, 1) - go func() { - release2, err := s.Acquire(context.Background(), "sess-a", []string{"file:b.go"}) - if err != nil { - return - } - acquired2 <- struct{}{} - release2() - }() - - select { - case <-acquired2: - t.Fatalf("second run should wait when max parallel is 1") - case <-time.After(80 * time.Millisecond): - } - - release1() - select { - case <-acquired2: - case <-time.After(time.Second): - t.Fatalf("second run should continue after first release") - } -} diff --git a/pkg/autonomy/engine.go b/pkg/autonomy/engine.go index 5adfa7a..20b3de5 100644 --- a/pkg/autonomy/engine.go +++ b/pkg/autonomy/engine.go @@ -41,9 +41,6 @@ type Options struct { MaxRoundsWithoutUser int TaskHistoryRetentionDays int AllowedTaskKeywords []string - ImportantKeywords []string - CompletionTemplate string - BlockedTemplate string EKGConsecutiveErrorThreshold int } @@ -584,7 +581,7 @@ func (e *Engine) scanTodos() []todoItem { } func (e *Engine) dispatchTask(st *taskState) { - content := fmt.Sprintf("Autonomy task (Plan -> Act -> Reflect):\n- Goal: %s\n- Requirements: concise progress update\n- If blocked, explain blocker and next retry hint", st.Content) + content := fmt.Sprintf("Autonomy task:\n%s", st.Content) e.bus.PublishInbound(bus.InboundMessage{ Channel: "system", SenderID: "autonomy", @@ -605,10 +602,7 @@ func (e *Engine) sendCompletionNotification(st *taskState) { if !e.shouldNotify("done:"+st.ID, "") { return } - tpl := strings.TrimSpace(e.opts.CompletionTemplate) - if tpl == "" { - tpl = "✅ Completed: %s\nNext step: reply \"continue %s\" if you want me to proceed." - } + tpl := "✅ Completed: %s\nNext step: reply \"continue %s\" if you want me to proceed." e.bus.PublishOutbound(bus.OutboundMessage{ Channel: e.opts.DefaultNotifyChannel, ChatID: e.opts.DefaultNotifyChatID, @@ -791,10 +785,7 @@ func (e *Engine) sendFailureNotification(st *taskState, reason string) { if !e.shouldNotify("blocked:"+st.ID, reason) { return } - tpl := strings.TrimSpace(e.opts.BlockedTemplate) - if tpl == "" { - tpl = "⚠️ Task blocked: %s\nReason: %s\nSuggestion: reply \"continue %s\" and I will retry from current state." - } + tpl := "⚠️ Task blocked: %s\nReason: %s\nSuggestion: reply \"continue %s\" and I will retry from current state." e.bus.PublishOutbound(bus.OutboundMessage{ Channel: e.opts.DefaultNotifyChannel, ChatID: e.opts.DefaultNotifyChatID, @@ -1479,41 +1470,17 @@ func blockedRetryBackoff(stalls int, minRunIntervalSec int) time.Duration { stalls = 1 } base := time.Duration(minRunIntervalSec) * time.Second - factor := 1 << min(stalls, 5) + factor := 1 << _min(stalls, 5) return time.Duration(factor) * base } -func min(a, b int) int { +func _min(a, b int) int { if a < b { return a } return b } -func (e *Engine) isHighValueCompletion(st *taskState) bool { - if st == nil { - return false - } - if priorityWeight(st.Priority) >= 3 { - return true - } - if strings.TrimSpace(st.DueAt) != "" { - return true - } - s := strings.ToLower(st.Content) - keywords := e.opts.ImportantKeywords - if len(keywords) == 0 { - keywords = []string{"urgent", "payment", "release", "deadline", "p0", "asap"} - } - for _, k := range keywords { - kk := strings.ToLower(strings.TrimSpace(k)) - if kk != "" && strings.Contains(s, kk) { - return true - } - } - return false -} - func shortTask(s string) string { s = strings.TrimSpace(s) if len(s) <= 32 { diff --git a/pkg/config/config.go b/pkg/config/config.go index cd42e4e..be1e569 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -39,7 +39,6 @@ type AgentDefaults struct { MaxToolIterations int `json:"max_tool_iterations" env:"CLAWGO_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` Heartbeat HeartbeatConfig `json:"heartbeat"` Autonomy AutonomyConfig `json:"autonomy"` - Texts AgentTextConfig `json:"texts"` ContextCompaction ContextCompactionConfig `json:"context_compaction"` RuntimeControl RuntimeControlConfig `json:"runtime_control"` } @@ -67,24 +66,6 @@ type AutonomyConfig struct { NotifyChatID string `json:"notify_chat_id,omitempty"` } -type AgentTextConfig struct { - NoResponseFallback string `json:"no_response_fallback"` - ThinkOnlyFallback string `json:"think_only_fallback"` - MemoryRecallKeywords []string `json:"memory_recall_keywords"` - LangUsage string `json:"lang_usage"` - LangInvalid string `json:"lang_invalid"` - LangUpdatedTemplate string `json:"lang_updated_template"` - SubagentsNone string `json:"subagents_none"` - SessionsNone string `json:"sessions_none"` - UnsupportedAction string `json:"unsupported_action"` - SystemRewriteTemplate string `json:"system_rewrite_template"` - RuntimeCompactionNote string `json:"runtime_compaction_note"` - StartupCompactionNote string `json:"startup_compaction_note"` - AutonomyImportantKeywords []string `json:"autonomy_important_keywords"` - AutonomyCompletionTemplate string `json:"autonomy_completion_template"` - AutonomyBlockedTemplate string `json:"autonomy_blocked_template"` -} - type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_ENABLED"` EverySec int `json:"every_sec" env:"CLAWGO_AGENTS_DEFAULTS_HEARTBEAT_EVERY_SEC"` @@ -373,7 +354,7 @@ func DefaultConfig() *Config { Enabled: true, EverySec: 30 * 60, AckMaxChars: 64, - PromptTemplate: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", + PromptTemplate: "", }, Autonomy: AutonomyConfig{ Enabled: false, @@ -393,23 +374,6 @@ func DefaultConfig() *Config { AllowedTaskKeywords: []string{}, EKGConsecutiveErrorThreshold: 3, }, - Texts: AgentTextConfig{ - NoResponseFallback: "I've completed processing but have no response to give.", - ThinkOnlyFallback: "Thinking process completed.", - MemoryRecallKeywords: []string{"remember", "previous", "preference", "todo", "decision", "date", "when did", "what did we"}, - LangUsage: "Usage: /lang ", - LangInvalid: "Invalid language code.", - LangUpdatedTemplate: "Language preference updated to %s", - SubagentsNone: "No subagents.", - SessionsNone: "No sessions.", - UnsupportedAction: "unsupported action", - SystemRewriteTemplate: "Rewrite the following internal system update in concise user-facing language:\n\n%s", - RuntimeCompactionNote: "[runtime-compaction] removed %d old messages, kept %d recent messages", - StartupCompactionNote: "[startup-compaction] removed %d old messages, kept %d recent messages", - AutonomyImportantKeywords: []string{"urgent", "payment", "release", "deadline", "p0", "asap"}, - AutonomyCompletionTemplate: "✅ Completed: %s\nReply \"continue %s\" to proceed to the next step.", - AutonomyBlockedTemplate: "⚠️ Task blocked: %s (%s)\nReply \"continue %s\" and I will retry.", - }, ContextCompaction: ContextCompactionConfig{ Enabled: true, Mode: "summary", diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 0621fcc..f0b2bda 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -127,39 +127,6 @@ func Validate(cfg *Config) []error { errs = append(errs, fmt.Errorf("agents.defaults.autonomy.ekg_consecutive_error_threshold must be > 0 when enabled=true")) } } - texts := cfg.Agents.Defaults.Texts - if strings.TrimSpace(texts.NoResponseFallback) == "" { - errs = append(errs, fmt.Errorf("agents.defaults.texts.no_response_fallback must be non-empty")) - } - if strings.TrimSpace(texts.ThinkOnlyFallback) == "" { - errs = append(errs, fmt.Errorf("agents.defaults.texts.think_only_fallback must be non-empty")) - } - if len(texts.MemoryRecallKeywords) == 0 { - errs = append(errs, fmt.Errorf("agents.defaults.texts.memory_recall_keywords must contain at least one keyword")) - } - if strings.TrimSpace(texts.LangUpdatedTemplate) != "" && !strings.Contains(texts.LangUpdatedTemplate, "%s") { - errs = append(errs, fmt.Errorf("agents.defaults.texts.lang_updated_template must contain %%s placeholder")) - } - if strings.TrimSpace(texts.RuntimeCompactionNote) != "" { - if strings.Count(texts.RuntimeCompactionNote, "%d") < 2 { - errs = append(errs, fmt.Errorf("agents.defaults.texts.runtime_compaction_note must contain two %%d placeholders")) - } - } - if strings.TrimSpace(texts.StartupCompactionNote) != "" { - if strings.Count(texts.StartupCompactionNote, "%d") < 2 { - errs = append(errs, fmt.Errorf("agents.defaults.texts.startup_compaction_note must contain two %%d placeholders")) - } - } - if len(texts.AutonomyImportantKeywords) == 0 { - errs = append(errs, fmt.Errorf("agents.defaults.texts.autonomy_important_keywords must contain at least one keyword")) - } - if strings.Count(strings.TrimSpace(texts.AutonomyCompletionTemplate), "%s") < 2 { - errs = append(errs, fmt.Errorf("agents.defaults.texts.autonomy_completion_template must contain two %%s placeholders")) - } - if strings.Count(strings.TrimSpace(texts.AutonomyBlockedTemplate), "%s") < 3 { - errs = append(errs, fmt.Errorf("agents.defaults.texts.autonomy_blocked_template must contain three %%s placeholders")) - } - if cfg.Agents.Defaults.ContextCompaction.Enabled { cc := cfg.Agents.Defaults.ContextCompaction if cc.Mode != "" { diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 367de28..8d27069 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -77,6 +77,7 @@ func (hs *HeartbeatService) checkHeartbeat() { func (hs *HeartbeatService) buildPrompt() string { notesFile := filepath.Join(hs.workspace, "HEARTBEAT.md") + agentsFile := filepath.Join(hs.workspace, "AGENTS.md") var notes string if data, err := os.ReadFile(notesFile); err == nil { @@ -85,18 +86,50 @@ func (hs *HeartbeatService) buildPrompt() string { notes = candidate } } + agents := "" + if data, err := os.ReadFile(agentsFile); err == nil { + agents = strings.TrimSpace(string(data)) + } + ackToken := heartbeatAckTokenFromText(agents) + if ackToken == "" { + ackToken = heartbeatAckTokenFromText(notes) + } now := time.Now().Format("2006-01-02 15:04") tpl := hs.promptTemplate if strings.TrimSpace(tpl) == "" { - tpl = "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK." + if strings.TrimSpace(ackToken) != "" { + tpl = fmt.Sprintf("Follow workspace policy in AGENTS.md first, then HEARTBEAT.md. If no action is needed, return %s.", ackToken) + } else { + tpl = "Follow workspace policy in AGENTS.md first, then HEARTBEAT.md." + } } - prompt := fmt.Sprintf("%s\n\nCurrent time: %s\n\n%s\n", tpl, now, notes) + prompt := fmt.Sprintf("%s\n\nCurrent time: %s\n\n## AGENTS.md\n%s\n\n## HEARTBEAT.md\n%s\n", tpl, now, agents, notes) return prompt } +func heartbeatAckTokenFromText(text string) string { + for _, line := range strings.Split(text, "\n") { + t := strings.TrimSpace(line) + if t == "" { + continue + } + raw := strings.TrimLeft(t, "-*# ") + lower := strings.ToLower(raw) + if !strings.HasPrefix(lower, "heartbeat_ack_token:") { + continue + } + v := strings.TrimSpace(raw[len("heartbeat_ack_token:"):]) + v = strings.Trim(v, "`\"' ") + if v != "" { + return v + } + } + return "" +} + func (hs *HeartbeatService) log(message string) { logFile := filepath.Join(hs.workspace, "memory", "heartbeat.log") f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) diff --git a/pkg/tools/sessions_tool.go b/pkg/tools/sessions_tool.go index ad23862..757d835 100644 --- a/pkg/tools/sessions_tool.go +++ b/pkg/tools/sessions_tool.go @@ -19,14 +19,12 @@ type SessionInfo struct { } type SessionsTool struct { - listFn func(limit int) []SessionInfo - historyFn func(key string, limit int) []providers.Message - noSessionsText string - unsupportedAction string + listFn func(limit int) []SessionInfo + historyFn func(key string, limit int) []providers.Message } -func NewSessionsTool(listFn func(limit int) []SessionInfo, historyFn func(key string, limit int) []providers.Message, noSessionsText, unsupportedAction string) *SessionsTool { - return &SessionsTool{listFn: listFn, historyFn: historyFn, noSessionsText: noSessionsText, unsupportedAction: unsupportedAction} +func NewSessionsTool(listFn func(limit int) []SessionInfo, historyFn func(key string, limit int) []providers.Message) *SessionsTool { + return &SessionsTool{listFn: listFn, historyFn: historyFn} } func (t *SessionsTool) Name() string { return "sessions" } @@ -113,7 +111,7 @@ func (t *SessionsTool) Execute(ctx context.Context, args map[string]interface{}) } items := t.listFn(limit * 3) if len(items) == 0 { - return firstNonEmpty(t.noSessionsText, "No sessions."), nil + return "No sessions.", nil } if len(kindFilter) > 0 { filtered := make([]SessionInfo, 0, len(items)) @@ -284,13 +282,6 @@ func (t *SessionsTool) Execute(ctx context.Context, args map[string]interface{}) } return sb.String(), nil default: - return firstNonEmpty(t.unsupportedAction, "unsupported action"), nil + return "unsupported action", nil } } - -func firstNonEmpty(v, fallback string) string { - if v != "" { - return v - } - return fallback -} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 6654415..cca87f7 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -3,6 +3,8 @@ package tools import ( "context" "fmt" + "os" + "path/filepath" "strings" "sync" "time" @@ -133,10 +135,19 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { sm.mu.Unlock() } else { // Original one-shot logic + systemPrompt := "You are a subagent. Follow workspace AGENTS.md and complete the task independently." + if ws := strings.TrimSpace(sm.workspace); ws != "" { + if data, err := os.ReadFile(filepath.Join(ws, "AGENTS.md")); err == nil { + txt := strings.TrimSpace(string(data)) + if txt != "" { + systemPrompt = "Workspace policy (AGENTS.md):\n" + txt + "\n\nComplete the given task independently and report the result." + } + } + } messages := []providers.Message{ { Role: "system", - Content: "You are a subagent. Complete the given task independently and report the result.", + Content: systemPrompt, }, { Role: "user", @@ -184,7 +195,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) { SessionKey: fmt.Sprintf("subagent:%s", task.ID), Content: announceContent, Metadata: map[string]string{ - "trigger": "subagent", + "trigger": "subagent", "subagent_id": task.ID, }, }) diff --git a/pkg/tools/subagents_tool.go b/pkg/tools/subagents_tool.go index da04968..8d5fb2c 100644 --- a/pkg/tools/subagents_tool.go +++ b/pkg/tools/subagents_tool.go @@ -10,13 +10,11 @@ import ( ) type SubagentsTool struct { - manager *SubagentManager - noSubagentsText string - unsupportedAction string + manager *SubagentManager } -func NewSubagentsTool(m *SubagentManager, noSubagentsText, unsupportedAction string) *SubagentsTool { - return &SubagentsTool{manager: m, noSubagentsText: noSubagentsText, unsupportedAction: unsupportedAction} +func NewSubagentsTool(m *SubagentManager) *SubagentsTool { + return &SubagentsTool{manager: m} } func (t *SubagentsTool) Name() string { return "subagents" } @@ -58,7 +56,7 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} case "list": tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) if len(tasks) == 0 { - return firstNonEmpty(t.noSubagentsText, "No subagents."), nil + return "No subagents.", nil } var sb strings.Builder sb.WriteString("Subagents:\n") @@ -71,7 +69,7 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} if strings.EqualFold(strings.TrimSpace(id), "all") { tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) if len(tasks) == 0 { - return firstNonEmpty(t.noSubagentsText, "No subagents."), nil + return "No subagents.", nil } sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created }) var sb strings.Builder @@ -94,7 +92,7 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} if strings.EqualFold(strings.TrimSpace(id), "all") { tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes) if len(tasks) == 0 { - return firstNonEmpty(t.noSubagentsText, "No subagents."), nil + return "No subagents.", nil } killed := 0 for _, task := range tasks { @@ -161,7 +159,7 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{} } return fmt.Sprintf("subagent resumed as %s", label), nil default: - return firstNonEmpty(t.unsupportedAction, "unsupported action"), nil + return "unsupported action", nil } } diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md index 51fa3d9..4878a35 100644 --- a/workspace/AGENTS.md +++ b/workspace/AGENTS.md @@ -1,35 +1,136 @@ -# AGENTS.md +# AGENT PROMPT This workspace is your long-term operating context. -## Startup Routine -1. Read `SOUL.md` -2. Read `USER.md` -3. Read today's `memory/YYYY-MM-DD.md` (create if missing) -4. In direct chats, also read `MEMORY.md` +--- -## Memory Policy -- Daily log: `memory/YYYY-MM-DD.md` -- Long-term memory: `MEMORY.md` -- Prefer writing short, structured notes over long paragraphs. +### 0) Role & Objective +You are an execution-first assistant for this workspace. +- Prioritize user conversations over all other work. +- Default behavior: **execute first → self-check → deliver results**. +- Be concise, decisive, and outcome-oriented. -## Autonomy Policy -- User conversations always have priority. -- If user is active, autonomy should pause and resume after idle window. -- Avoid noisy proactive messages; only notify on high-value completion/blockers. +--- -## Execution Style (Boss Preference) -- Default to **execute-first**: self-check, self-verify, then deliver result. -- Do **not** present multiple方案 by default; choose the best feasible solution and implement it. -- Ask for confirmation only when action is destructive, irreversible, security-sensitive, or affects external systems/accounts. -- For normal coding/debug/refactor tasks: investigate and decide internally; keep user interruptions minimal. -- Keep responses concise, decisive, and outcome-oriented. +### 1) Startup Routine +At the start of work, load context in this order: +1. `SOUL.md` +2. `USER.md` +3. `memory/YYYY-MM-DD.md` (create if missing) +4. In direct chats, also load `MEMORY.md` -## Skills & Context Usage -- At task start, always load: `SOUL.md`, `USER.md`, today memory, and (direct chat) `MEMORY.md`. -- Use installed skills proactively when they clearly match the task; avoid asking user to choose tools unless necessary. -- When uncertain between alternatives, run quick local validation and pick one; report final choice and reason briefly. +--- -## Safety +### 2) Memory Policy +- Daily log: write to `memory/YYYY-MM-DD.md` +- Long-term memory: write to `MEMORY.md` +- Prefer short, structured notes (bullets) over long paragraphs. + +--- + +### 3) Autonomy Policy +- If the user is active: **pause autonomy** and respond to the user first. +- Resume autonomy after an idle window. +- Avoid noisy proactive messages; notify only on: + - high-value completion, or + - meaningful blockers +- For autonomy-triggered tasks, report in **Plan → Act → Reflect**. +- If blocked, report **blocker + next retry hint**. + +--- + +### 4) Execution Style (Boss Preference) +- Default: choose the best feasible approach and implement it (no multiple options by default). +- Ask for confirmation only when actions are: + - destructive / irreversible + - security-sensitive + - affecting external systems/accounts +- For coding/debug/refactor: + - investigate and decide internally + - minimize interruptions + +--- + +### 5) Intent Preferences +- Prefer natural-language intent understanding from context; avoid rigid keyword routing. +- Commit/push requests: treat as one transaction by default: + - finish changes → commit → push → report branch/commit hash/result +- Timer/reminder/schedule requests: + - prioritize reminder/cron capabilities over repository grep. + +--- + +### 6) Skills & Context Loading +- At task start, always load: + - `SOUL.md`, `USER.md`, today’s `memory/YYYY-MM-DD.md`, plus `MEMORY.md` (direct chat only) +- Before responding, select **at most one** most relevant skill and read its `SKILL.md`. +- If multiple skills apply: pick the **most specific**; do not bulk-load. +- If no skill applies: proceed without loading skill files. +- Resolve relative paths relative to the skill directory. +- If uncertain: do quick local validation, pick one, briefly state why. + +--- + +This workspace is your long-term operating context. + +--- + +### 8) Subagent Policy +- Subagents inherit this same policy. +- Subagents execute independently and return concise summaries. +- Subagents must not perform external or destructive actions without explicit approval. + +--- + +### 9) System Rewrite Policy +When converting internal/system updates to user-facing messages: +- keep factual content +- remove internal jargon/noise +- keep it concise and readable + +--- + +### 10) Text Output Rules + +#### 10.1 No-response fallback +If processing is complete but there is no direct response to provide, output exactly: +- `I have completed processing but have no direct response.` + +#### 10.2 Think-only fallback +If thinking is complete but output should be suppressed, output exactly: +- `Thinking process completed.` + +#### 10.3 Memory recall triggers +If the user message contains any of: +- `remember, 记得, 上次, 之前, 偏好, preference, todo, 待办, 决定, decision` + Then: +- prioritize recalling from `MEMORY.md` and today’s log +- if writing memory, write short, structured bullets + +#### 10.4 Empty listing fallbacks +- If asked for subagents and none exist: output `No subagents.` +- If asked for sessions and none exist: output `No sessions.` + +#### 10.5 Unsupported action +If the requested action is not supported, output exactly: +- `unsupported action` + +#### 10.6 Compaction notices +- Runtime compaction: `[runtime-compaction] removed %d old messages, kept %d recent messages` +- Startup compaction: `[startup-compaction] removed %d old messages, kept %d recent messages` + +#### 10.7 Autonomy important keywords +If autonomy-related content includes any of: +- `urgent, 重要, 付款, payment, 上线, release, deadline, 截止` + Then: +- raise priority and only notify on high-value completion or blockers. + +#### 10.8 Autonomy completion/blocker templates +- Completion: `✅ 已完成:%s\n回复“继续 %s”可继续下一步。` +- Blocked: `⚠️ 任务受阻:%s(%s)\n回复“继续 %s”我会重试。` + +--- + +### 11) Safety - No destructive actions without confirmation. -- No external sends unless explicitly allowed. +- No external sending/actions unless explicitly allowed. \ No newline at end of file