This commit is contained in:
lpf
2026-03-05 12:55:30 +08:00
parent 2fbb98bccd
commit 94e8f2c9d9
18 changed files with 378 additions and 1126 deletions

View File

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

View File

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

View File

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

View File

@@ -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 <skill><description> entries in <skills>.
- 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")
}

View File

@@ -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 <code>
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 <code>"
}
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 <think>...</think> 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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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 != "" {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`, todays `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 todays 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.