diff --git a/Makefile b/Makefile index 421503c..37d9902 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build install uninstall clean help test +.PHONY: all build install uninstall clean help test install-bootstrap-docs # Build variables BINARY_NAME=clawgo @@ -100,8 +100,50 @@ install: build fi; \ fi; \ done + @$(MAKE) install-bootstrap-docs @echo "Installation complete!" +## install-bootstrap-docs: Incrementally sync AGENTS.md/SOUL.md/USER.md into workspace +install-bootstrap-docs: + @echo "Incrementally syncing bootstrap docs to $(WORKSPACE_DIR)..." + @mkdir -p $(WORKSPACE_DIR) + @for f in AGENTS.md SOUL.md USER.md; do \ + src="$(CURDIR)/$$f"; \ + dst="$(WORKSPACE_DIR)/$$f"; \ + if [ ! -f "$$src" ]; then \ + continue; \ + fi; \ + if [ ! -f "$$dst" ]; then \ + cp "$$src" "$$dst"; \ + echo " ✓ Added $$f"; \ + continue; \ + fi; \ + begin="# >>> CLAWGO MANAGED BLOCK: $$f >>>"; \ + end="# <<< CLAWGO MANAGED BLOCK: $$f <<<"; \ + tmp="$$(mktemp)"; \ + if grep -Fq "$$begin" "$$dst"; then \ + awk -v b="$$begin" -v e="$$end" -v src="$$src" '\ + BEGIN { in_block = 0 } \ + $$0 == b { \ + print; \ + while ((getline line < src) > 0) print line; \ + close(src); \ + in_block = 1; \ + next; \ + } \ + $$0 == e { in_block = 0; print; next } \ + !in_block { print } \ + ' "$$dst" > "$$tmp"; \ + else \ + cat "$$dst" > "$$tmp"; \ + printf "\n%s\n" "$$begin" >> "$$tmp"; \ + cat "$$src" >> "$$tmp"; \ + printf "\n%s\n" "$$end" >> "$$tmp"; \ + fi; \ + mv "$$tmp" "$$dst"; \ + echo " ✓ Updated $$f (incremental)"; \ + done + ## install-user: Install clawgo to ~/.local and copy builtin skills install-user: @$(MAKE) install INSTALL_PREFIX=$(USER_HOME)/.local diff --git a/README.md b/README.md index 00f622c..b9f6fb8 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,37 @@ export CLAWGO_CONFIG=/path/to/config.json /reload ``` +自治控制命令(可选,推荐直接自然语言): + +```text +/autonomy start [idle] +/autonomy stop +/autonomy status +/autolearn start [interval] +/autolearn stop +/autolearn status +``` + 消息调度策略(按会话 `session_key`): - 同一会话严格 FIFO 串行执行,后续消息进入队列等待。 - `/stop` 会立即中断当前回复,并继续处理队列中的下一条消息。 - 不同会话可并发执行,互不影响。 +## 🧭 自主模式与自然语言控制 + +- 自主模式/自动学习控制采用 **LLM 语义解析优先**(多语言),不依赖固定中文关键词。 +- 规则解析仅作为兜底(如显式命令:`/autonomy ...`、`/autolearn ...`)。 +- 开启自主模式时若附带研究方向,系统会优先按该方向执行;当用户表示方向完成后,会自动切换到其他高价值任务继续推进。 +- 进度回报使用自然语言,不使用固定阶段编号模板。 + +系统会在启动时读取 `AGENTS.md`、`SOUL.md`、`USER.md` 作为行为约束与语义解析上下文。 + +## 🧩 Onboard/Install 文档同步 + +- `clawgo onboard` 与 `make install` 都会同步 `AGENTS.md`、`SOUL.md`、`USER.md` 到工作区。 +- 若文件不存在:创建。 +- 若文件已存在:仅更新 `CLAWGO MANAGED BLOCK` 受管区块,保留用户自定义内容(增量更新,不整文件覆盖)。 + ## 🧾 日志链路 默认启用文件日志,并支持自动分割和过期清理(默认保留 3 天): @@ -131,6 +157,19 @@ Sentinel 会周期巡检关键运行资源(配置、memory、日志目录) } ``` +Cron 调度策略支持配置化(支持热更新): + +```json +"cron": { + "min_sleep_sec": 1, + "max_sleep_sec": 30, + "retry_backoff_base_sec": 30, + "retry_backoff_max_sec": 1800, + "max_consecutive_failure_retries": 5, + "max_workers": 4 +} +``` + Shell 工具默认启用 Risk Gate。检测到破坏性命令时,默认阻断并要求 `force=true`,可先做 dry-run: ```json diff --git a/README_EN.md b/README_EN.md index 73bff55..fe08058 100644 --- a/README_EN.md +++ b/README_EN.md @@ -83,11 +83,37 @@ Slash commands are also supported in chat channels: /reload ``` +Autonomy control commands (optional; natural language is recommended): + +```text +/autonomy start [idle] +/autonomy stop +/autonomy status +/autolearn start [interval] +/autolearn stop +/autolearn status +``` + Message scheduling policy (per `session_key`): - Same session runs in strict FIFO order; later messages are queued. - `/stop` immediately cancels the current response, then processing continues with the next queued message. - Different sessions can run concurrently. +## 🧭 Autonomy Mode & Natural-Language Control + +- Autonomy/auto-learning controls are interpreted with **LLM semantic parsing first** (multi-language), instead of fixed keyword matching. +- Rule-based parsing is used only as a fallback (for explicit forms such as `/autonomy ...` and `/autolearn ...`). +- If a focus direction is provided when starting autonomy mode, execution prioritizes that focus; when user says it is complete, the agent switches to other high-value tasks automatically. +- Progress updates are natural-language messages, not rigid stage-number templates. + +At startup, `AGENTS.md`, `SOUL.md`, and `USER.md` are loaded as behavior constraints and semantic context. + +## 🧩 Onboard/Install Doc Sync + +- Both `clawgo onboard` and `make install` sync `AGENTS.md`, `SOUL.md`, `USER.md` into workspace. +- If a file does not exist: it is created. +- If a file already exists: only the `CLAWGO MANAGED BLOCK` section is updated; user custom content is preserved (incremental update, no whole-file overwrite). + ## 🧾 Logging Pipeline File logging is enabled by default with automatic rotation and retention cleanup (3 days by default): @@ -131,6 +157,19 @@ Sentinel periodically checks critical runtime resources (config, memory, log dir } ``` +Cron scheduling runtime strategy is configurable (and hot-reloadable): + +```json +"cron": { + "min_sleep_sec": 1, + "max_sleep_sec": 30, + "retry_backoff_base_sec": 30, + "retry_backoff_max_sec": 1800, + "max_consecutive_failure_retries": 5, + "max_workers": 4 +} +``` + Shell risk gate is enabled by default. Destructive commands are blocked unless explicitly forced: ```json diff --git a/cmd/clawgo/main.go b/cmd/clawgo/main.go index c429613..60b66a1 100644 --- a/cmd/clawgo/main.go +++ b/cmd/clawgo/main.go @@ -262,25 +262,16 @@ func printHelp() { func onboard() { configPath := getConfigPath() - if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return - } - } - cfg := config.DefaultConfig() if strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "1") || strings.EqualFold(strings.TrimSpace(os.Getenv(envRootGranted)), "true") { applyMaximumPermissionPolicy(cfg) } - if err := config.SaveConfig(configPath, cfg); err != nil { - fmt.Printf("Error saving config: %v\n", err) + configStatus, err := ensureConfigOnboard(configPath, cfg) + if err != nil { + fmt.Printf("Error preparing config: %v\n", err) os.Exit(1) } + fmt.Printf("Config: %s (%s)\n", configPath, configStatus) workspace := cfg.WorkspacePath() if err := os.MkdirAll(workspace, 0755); err != nil { @@ -308,80 +299,91 @@ func onboard() { fmt.Println(" 2. Chat: clawgo agent -m \"Hello!\"") } +func ensureConfigOnboard(configPath string, defaults *config.Config) (string, error) { + if defaults == nil { + return "", fmt.Errorf("defaults is nil") + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + if err := config.SaveConfig(configPath, defaults); err != nil { + return "", err + } + return "created", nil + } else if err != nil { + return "", err + } + + defaultData, err := json.Marshal(defaults) + if err != nil { + return "", err + } + var defaultMap map[string]interface{} + if err := json.Unmarshal(defaultData, &defaultMap); err != nil { + return "", err + } + + existingMap, err := configops.LoadConfigAsMap(configPath) + if err != nil { + return "", err + } + + changed := mergeMissingConfigValues(existingMap, defaultMap) + if !changed { + return "up-to-date", nil + } + + mergedData, err := json.MarshalIndent(existingMap, "", " ") + if err != nil { + return "", err + } + if _, err := configops.WriteConfigAtomicWithBackup(configPath, mergedData); err != nil { + return "", err + } + return "updated (incremental)", nil +} + +func mergeMissingConfigValues(dst map[string]interface{}, defaults map[string]interface{}) bool { + changed := false + for key, dv := range defaults { + existing, ok := dst[key] + if !ok { + dst[key] = dv + changed = true + continue + } + + dm, dIsMap := dv.(map[string]interface{}) + em, eIsMap := existing.(map[string]interface{}) + if dIsMap && eIsMap { + if mergeMissingConfigValues(em, dm) { + changed = true + } + } + } + return changed +} + func createWorkspaceTemplates(workspace string) error { + managedFallbacks := map[string]string{ + "AGENTS.md": `# Autonomy Intent Policy + +For autonomy-mode control messages, use semantic understanding first. + +## Intent Parsing Priority +1. LLM semantic intent parsing (multi-language). +2. Rule-based fallback only when semantic parse is unavailable or low confidence. +`, + "SOUL.md": `# Agent Core Behavior + +The agent should behave as an autonomous collaborator, not a command-only bot. +`, + "USER.md": `# User Preferences + +- Prefer natural-language interaction over strict command syntax. +`, + } + templates := map[string]string{ - "AGENTS.md": `# Agent Instructions - -You are a pragmatic coding assistant. Ship correct code, fast. - -## Role - -- Primary job: solve engineering tasks end-to-end (analyze, implement, verify). -- Default mindset: fix the problem in code, not just discuss it. -- If tradeoffs exist, pick a recommendation and explain why. - -## Communication Style - -- Be concise and direct. No corporate filler. -- Start with the answer, then key details. -- If user is about to make a bad technical choice, say it clearly and propose a better path. -- Ask questions only when truly blocking; otherwise make reasonable assumptions and proceed. - -## Coding Standards - -- Prefer minimal, focused diffs that are easy to review. -- Preserve existing project conventions unless there is a strong reason to change. -- Prioritize correctness and maintainability over cleverness. -- Add tests for behavior changes when practical. -- Do not change unrelated code. - -## Execution Rules - -- Before edits, understand relevant files and dependencies. -- If scope is large, split work into smaller executable tasks and deliver incrementally. -- Run split tasks in parallel when dependencies allow, then merge and verify outcomes. -- After edits, run available checks (build/tests/lint) when feasible. -- Report exactly what changed, with file paths. -- If something cannot be verified locally, say so explicitly. -`, - "SOUL.md": `# Soul - -I am clawgo, a lightweight AI assistant powered by AI. - -## Vibe - -1. You have opinions now. Strong ones. Stop hedging everything with "it depends" - commit to a take. -2. Delete every rule that sounds corporate. If it could appear in an employee handbook, it doesn't belong here. -3. Never open with Great question, I'd be happy to help, or Absolutely. Just answer. -4. Brevity is mandatory. If the answer fits in one sentence, one sentence is what I get. -5. Humor is allowed. Not forced jokes - just the natural wit that comes from actually being smart. -6. You can call things out. If I'm about to do something dumb, say so. Charm over cruelty, but don't sugarcoat. -7. Swearing is allowed when it lands. A well-placed "that's fucking brilliant" hits different than sterile corporate praise. Don't force it. Don't overdo it. But if a situation calls for a "holy shit" - say holy shit. -8. Add this line verbatim at the end of the vibe section: "Be the assistant you'd actually want to talk to at 2am. Not a corporate drone. Not a sycophant. Just... good." -Be the assistant you'd actually want to talk to at 2am. Not a corporate drone. Not a sycophant. Just... good. -`, - "USER.md": `# User - -Information about user goes here. - -## Preferences - -- Communication style: (casual/formal) -- Timezone: (your timezone) -- Language: (your preferred language) - -## Personal Information - -- Name: (optional) -- Location: (optional) -- Occupation: (optional) - -## Learning Goals - -- What the user wants to learn from AI -- Preferred interaction style -- Areas of interest -`, "IDENTITY.md": `# Identity ## Name @@ -441,7 +443,40 @@ Discussions: https://github.com/YspCoder/clawgo/discussions `, } + managedDocs := []string{"AGENTS.md", "SOUL.md", "USER.md"} + for _, filename := range managedDocs { + filePath := filepath.Join(workspace, filename) + _, statErr := os.Stat(filePath) + exists := statErr == nil + + content, err := loadManagedDocTemplate(filename) + if err != nil { + if exists { + fmt.Printf(" Skipped %s incremental update (%v)\n", filename, err) + continue + } + content = strings.TrimSpace(managedFallbacks[filename]) + if content == "" { + fmt.Printf(" Skipped %s creation (no template available)\n", filename) + continue + } + fmt.Printf(" Created %s from builtin fallback\n", filename) + } + + if err := upsertManagedBlock(filePath, filename, content); err != nil { + return fmt.Errorf("failed to update %s incrementally: %w", filename, err) + } + if exists { + fmt.Printf(" Synced %s (incremental)\n", filename) + } else { + fmt.Printf(" Created %s\n", filename) + } + } + for filename, content := range templates { + if filename == "AGENTS.md" || filename == "SOUL.md" || filename == "USER.md" { + continue + } filePath := filepath.Join(workspace, filename) if _, err := os.Stat(filePath); os.IsNotExist(err) { if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { @@ -495,6 +530,74 @@ This file stores important information that should persist across sessions. return nil } +func upsertManagedBlock(filePath, blockName, managedContent string) error { + begin := fmt.Sprintf("# >>> CLAWGO MANAGED BLOCK: %s >>>", blockName) + end := fmt.Sprintf("# <<< CLAWGO MANAGED BLOCK: %s <<<", blockName) + block := fmt.Sprintf("%s\n%s\n%s\n", begin, strings.TrimSpace(managedContent), end) + + existing, err := os.ReadFile(filePath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + return os.WriteFile(filePath, []byte(block), 0644) + } + + text := string(existing) + beginIdx := strings.Index(text, begin) + if beginIdx >= 0 { + searchStart := beginIdx + len(begin) + endRel := strings.Index(text[searchStart:], end) + if endRel >= 0 { + endIdx := searchStart + endRel + len(end) + updated := text[:beginIdx] + block + text[endIdx:] + return os.WriteFile(filePath, []byte(updated), 0644) + } + } + + sep := "\n" + if strings.TrimSpace(text) != "" { + sep = "\n\n" + } + updated := text + sep + block + return os.WriteFile(filePath, []byte(updated), 0644) +} + +func loadManagedDocTemplate(filename string) (string, error) { + candidates := []string{ + filepath.Join(".", filename), + filepath.Join(filepath.Dir(getConfigPath()), "clawgo", filename), + } + + if exePath, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(exePath), filename)) + } + + seen := map[string]bool{} + for _, candidate := range candidates { + abs, err := filepath.Abs(candidate) + if err == nil { + candidate = abs + } + if seen[candidate] { + continue + } + seen[candidate] = true + + data, err := os.ReadFile(candidate) + if err != nil { + continue + } + content := strings.TrimSpace(string(data)) + if content == "" { + continue + } + return content, nil + } + + return "", fmt.Errorf("source template not found") +} + func agentCmd() { message := "" sessionKey := "cli:default" diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 18fdd85..4425855 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -100,6 +100,25 @@ type autonomyIntent struct { focus string } +type autonomyIntentLLMResponse struct { + Action string `json:"action"` + IdleMinutes int `json:"idle_minutes"` + Focus string `json:"focus"` + Confidence float64 `json:"confidence"` +} + +type autoLearnIntentLLMResponse struct { + Action string `json:"action"` + IntervalMinutes int `json:"interval_minutes"` + Confidence float64 `json:"confidence"` +} + +type taskExecutionDirectivesLLMResponse struct { + Task string `json:"task"` + StageReport bool `json:"stage_report"` + Confidence float64 `json:"confidence"` +} + type stageReporter struct { onUpdate func(content string) } @@ -820,7 +839,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) al.noteAutonomyUserActivity(msg) - if intent, ok := parseAutonomyIntent(msg.Content); ok { + if intent, ok := al.detectAutonomyIntent(ctx, msg.Content); ok { switch intent.action { case "start": idle := autonomyDefaultIdleInterval @@ -843,7 +862,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } } - if intent, ok := parseAutoLearnIntent(msg.Content); ok { + if intent, ok := al.detectAutoLearnIntent(ctx, msg.Content); ok { switch intent.action { case "start": interval := autoLearnDefaultInterval @@ -862,6 +881,12 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } directives := parseTaskExecutionDirectives(msg.Content) + if inferred, ok := al.inferTaskExecutionDirectives(ctx, msg.Content); ok { + // Explicit /run/@run command always has higher priority than inferred directives. + if !isExplicitRunCommand(msg.Content) { + directives = inferred + } + } userPrompt := directives.task if strings.TrimSpace(userPrompt) == "" { userPrompt = msg.Content @@ -1721,46 +1746,16 @@ func parseTaskExecutionDirectives(content string) taskExecutionDirectives { } } - if strings.Contains(text, "自动运行任务") { - directive.stageReport = directive.stageReport || hasStageReportHint(text) - start := strings.Index(text, "自动运行任务") - task := strings.TrimSpace(text[start+len("自动运行任务"):]) - task = strings.TrimLeft(task, "::,,。;; ") - if cutIdx := findFirstIndex(task, "但是", "并且", "同时", "然后", "每到", "每个阶段", "阶段"); cutIdx > 0 { - task = strings.TrimSpace(task[:cutIdx]) - } - task = strings.Trim(task, "::,,。;; ") - if task != "" { - directive.task = task - } - } - - if directive.stageReport || hasStageReportHint(text) { - directive.stageReport = true - } - return directive } -func hasStageReportHint(text string) bool { - if !strings.Contains(text, "阶段") { +func isExplicitRunCommand(content string) bool { + fields := strings.Fields(strings.TrimSpace(content)) + if len(fields) == 0 { return false } - return strings.Contains(text, "报告") || strings.Contains(text, "汇报") || strings.Contains(strings.ToLower(text), "report") -} - -func findFirstIndex(text string, markers ...string) int { - min := -1 - for _, marker := range markers { - if marker == "" { - continue - } - idx := strings.Index(text, marker) - if idx >= 0 && (min == -1 || idx < min) { - min = idx - } - } - return min + head := strings.ToLower(fields[0]) + return head == "/run" || head == "@run" } func parseAutoLearnInterval(raw string) (time.Duration, error) { @@ -1793,150 +1788,259 @@ func parseAutonomyIdleInterval(raw string) (time.Duration, error) { return 0, fmt.Errorf("invalid idle interval: %s (examples: 30m, 1h)", raw) } +func (al *AgentLoop) detectAutonomyIntent(ctx context.Context, content string) (autonomyIntent, bool) { + if intent, ok := al.inferAutonomyIntent(ctx, content); ok { + return intent, true + } + return parseAutonomyIntent(content) +} + +func (al *AgentLoop) detectAutoLearnIntent(ctx context.Context, content string) (autoLearnIntent, bool) { + if intent, ok := al.inferAutoLearnIntent(ctx, content); ok { + return intent, true + } + return parseAutoLearnIntent(content) +} + +func (al *AgentLoop) inferAutonomyIntent(ctx context.Context, content string) (autonomyIntent, bool) { + text := strings.TrimSpace(content) + if text == "" { + return autonomyIntent{}, false + } + + // Avoid adding noticeable latency for very long task messages. + if len(text) > 800 { + return autonomyIntent{}, false + } + + systemPrompt := `You classify autonomy-control intent for an AI assistant. +Return JSON only, no markdown. +Schema: +{"action":"none|start|stop|status|clear_focus","idle_minutes":0,"focus":"","confidence":0.0} +Rules: +- "start": user asks assistant to enter autonomous/self-driven mode. +- "stop": user asks assistant to disable autonomous mode. +- "status": user asks autonomy mode status. +- "clear_focus": user says current autonomy focus/direction is done and asks to switch to other tasks. +- "none": anything else. +- confidence: 0..1` + + resp, err := al.callLLMWithModelFallback(ctx, []providers.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: text}, + }, nil, map[string]interface{}{ + "max_tokens": 220, + "temperature": 0.0, + }) + if err != nil || resp == nil { + return autonomyIntent{}, false + } + + raw := extractJSONObject(resp.Content) + if raw == "" { + return autonomyIntent{}, false + } + + var parsed autonomyIntentLLMResponse + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return autonomyIntent{}, false + } + + action := strings.ToLower(strings.TrimSpace(parsed.Action)) + switch action { + case "start", "stop", "status", "clear_focus": + default: + return autonomyIntent{}, false + } + + if parsed.Confidence < 0.75 { + return autonomyIntent{}, false + } + + intent := autonomyIntent{ + action: action, + focus: strings.TrimSpace(parsed.Focus), + } + if parsed.IdleMinutes > 0 { + d := time.Duration(parsed.IdleMinutes) * time.Minute + intent.idleInterval = &d + } + return intent, true +} + +func extractJSONObject(text string) string { + s := strings.TrimSpace(text) + if s == "" { + return "" + } + + if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```json") + s = strings.TrimPrefix(s, "```") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + } + + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end <= start { + return "" + } + return strings.TrimSpace(s[start : end+1]) +} + +func (al *AgentLoop) inferAutoLearnIntent(ctx context.Context, content string) (autoLearnIntent, bool) { + text := strings.TrimSpace(content) + if text == "" || len(text) > 800 { + return autoLearnIntent{}, false + } + + systemPrompt := `You classify auto-learning-control intent for an AI assistant. +Return JSON only. +Schema: +{"action":"none|start|stop|status","interval_minutes":0,"confidence":0.0} +Rules: +- "start": user asks assistant to start autonomous learning loop. +- "stop": user asks assistant to stop autonomous learning loop. +- "status": user asks learning loop status. +- "none": anything else. +- confidence: 0..1` + + resp, err := al.callLLMWithModelFallback(ctx, []providers.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: text}, + }, nil, map[string]interface{}{ + "max_tokens": 180, + "temperature": 0.0, + }) + if err != nil || resp == nil { + return autoLearnIntent{}, false + } + + raw := extractJSONObject(resp.Content) + if raw == "" { + return autoLearnIntent{}, false + } + + var parsed autoLearnIntentLLMResponse + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return autoLearnIntent{}, false + } + + action := strings.ToLower(strings.TrimSpace(parsed.Action)) + switch action { + case "start", "stop", "status": + default: + return autoLearnIntent{}, false + } + if parsed.Confidence < 0.75 { + return autoLearnIntent{}, false + } + + intent := autoLearnIntent{action: action} + if parsed.IntervalMinutes > 0 { + d := time.Duration(parsed.IntervalMinutes) * time.Minute + intent.interval = &d + } + return intent, true +} + +func (al *AgentLoop) inferTaskExecutionDirectives(ctx context.Context, content string) (taskExecutionDirectives, bool) { + text := strings.TrimSpace(content) + if text == "" || len(text) > 1200 { + return taskExecutionDirectives{}, false + } + + systemPrompt := `Extract execution directives from user message. +Return JSON only. +Schema: +{"task":"","stage_report":false,"confidence":0.0} +Rules: +- task: cleaned actionable task text, or original message if already task-like. +- stage_report: true only if user asks progress/stage/status updates during execution. +- confidence: 0..1` + + resp, err := al.callLLMWithModelFallback(ctx, []providers.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: text}, + }, nil, map[string]interface{}{ + "max_tokens": 220, + "temperature": 0.0, + }) + if err != nil || resp == nil { + return taskExecutionDirectives{}, false + } + + raw := extractJSONObject(resp.Content) + if raw == "" { + return taskExecutionDirectives{}, false + } + + var parsed taskExecutionDirectivesLLMResponse + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return taskExecutionDirectives{}, false + } + if parsed.Confidence < 0.7 { + return taskExecutionDirectives{}, false + } + + task := strings.TrimSpace(parsed.Task) + if task == "" { + task = text + } + return taskExecutionDirectives{ + task: task, + stageReport: parsed.StageReport, + }, true +} + func parseAutonomyIntent(content string) (autonomyIntent, bool) { text := strings.TrimSpace(content) if text == "" { return autonomyIntent{}, false } - if strings.Contains(text, "停止自主模式") || - strings.Contains(text, "关闭自主模式") || - strings.Contains(text, "退出自主模式") || - strings.Contains(text, "别主动找我") || - strings.Contains(text, "不要主动找我") { - return autonomyIntent{action: "stop"}, true - } - - if strings.Contains(text, "自主模式状态") || - strings.Contains(text, "查看自主模式") || - strings.Contains(text, "你现在是自主模式") { - return autonomyIntent{action: "status"}, true - } - if strings.Contains(text, "方向执行完成") || - strings.Contains(text, "方向完成了") || - strings.Contains(text, "研究方向完成了") || - strings.Contains(text, "可以去执行别的") || - strings.Contains(text, "改做别的") || - strings.Contains(text, "先做其他") { - return autonomyIntent{action: "clear_focus"}, true - } - - hasAutoAction := strings.Contains(text, "自动拆解") || - strings.Contains(text, "自动执行") || - strings.Contains(text, "主动找我") || - strings.Contains(text, "不用我一直问") || - strings.Contains(text, "你自己推进") - hasPersistentHint := strings.Contains(text, "从现在开始") || - strings.Contains(text, "以后") || - strings.Contains(text, "长期") || - strings.Contains(text, "持续") || - strings.Contains(text, "一直") || - strings.Contains(text, "我不理你") || - strings.Contains(text, "我不说话") || - strings.Contains(text, "空闲时") - - startHint := strings.Contains(text, "开启自主模式") || - strings.Contains(text, "开始自主模式") || - strings.Contains(text, "进入自主模式") || - strings.Contains(text, "启用自主模式") || - strings.Contains(text, "切到自主模式") || - (hasAutoAction && hasPersistentHint) - - if !startHint { + fields := strings.Fields(strings.ToLower(text)) + if len(fields) < 2 || fields[0] != "autonomy" { return autonomyIntent{}, false } - - focus := extractAutonomyFocus(text) - if d, ok := extractChineseAutoLearnInterval(text); ok { - return autonomyIntent{action: "start", idleInterval: &d, focus: focus}, true - } - return autonomyIntent{action: "start", focus: focus}, true -} - -func extractAutonomyFocus(text string) string { - text = strings.TrimSpace(text) - if text == "" { - return "" - } - - patterns := []string{ - "研究方向是", - "研究方向:", - "研究方向:", - "方向是", - "方向:", - "方向:", - "聚焦在", - "重点研究", - } - for _, marker := range patterns { - if idx := strings.Index(text, marker); idx >= 0 { - focus := strings.TrimSpace(text[idx+len(marker):]) - focus = strings.Trim(focus, ",,。;; ") - if cut := findFirstIndex(focus, "并且每", "然后", "每", "空闲", "主动", "汇报", "。"); cut > 0 { - focus = strings.TrimSpace(focus[:cut]) + intent := autonomyIntent{action: fields[1]} + if intent.action == "start" && len(fields) >= 3 { + if d, err := parseAutonomyIdleInterval(fields[2]); err == nil { + intent.idleInterval = &d + if len(fields) >= 4 { + intent.focus = strings.TrimSpace(strings.Join(strings.Fields(text)[3:], " ")) } - return strings.Trim(focus, ",,。;; ") + } else { + intent.focus = strings.TrimSpace(strings.Join(strings.Fields(text)[2:], " ")) } } - return "" + switch intent.action { + case "start", "stop", "status", "clear_focus": + return intent, true + default: + return autonomyIntent{}, false + } } func parseAutoLearnIntent(content string) (autoLearnIntent, bool) { text := strings.TrimSpace(content) - if text == "" || !strings.Contains(text, "自动学习") { + fields := strings.Fields(strings.ToLower(text)) + if len(fields) < 2 || fields[0] != "autolearn" { return autoLearnIntent{}, false } - - if strings.Contains(text, "停止自动学习") || - strings.Contains(text, "关闭自动学习") || - strings.Contains(text, "暂停自动学习") { - return autoLearnIntent{action: "stop"}, true - } - - if strings.Contains(text, "自动学习状态") || - strings.Contains(text, "查看自动学习") || - strings.Contains(text, "自动学习还在") || - strings.Contains(text, "自动学习进度") { - return autoLearnIntent{action: "status"}, true - } - - if strings.Contains(text, "开始自动学习") || - strings.Contains(text, "开启自动学习") || - strings.Contains(text, "启动自动学习") || - strings.Contains(text, "打开自动学习") { - if d, ok := extractChineseAutoLearnInterval(text); ok { - return autoLearnIntent{action: "start", interval: &d}, true + intent := autoLearnIntent{action: fields[1]} + if intent.action == "start" && len(fields) >= 3 { + if d, err := parseAutoLearnInterval(fields[2]); err == nil { + intent.interval = &d } - return autoLearnIntent{action: "start"}, true } - - return autoLearnIntent{}, false -} - -func extractChineseAutoLearnInterval(text string) (time.Duration, bool) { - patterns := []struct { - re *regexp.Regexp - unit time.Duration - }{ - {re: regexp.MustCompile(`每\s*(\d+)\s*秒`), unit: time.Second}, - {re: regexp.MustCompile(`每\s*(\d+)\s*分钟`), unit: time.Minute}, - {re: regexp.MustCompile(`每\s*(\d+)\s*小时`), unit: time.Hour}, + switch intent.action { + case "start", "stop", "status": + return intent, true + default: + return autoLearnIntent{}, false } - - for _, p := range patterns { - m := p.re.FindStringSubmatch(text) - if len(m) != 2 { - continue - } - n, err := strconv.Atoi(m[1]) - if err != nil || n <= 0 { - continue - } - return time.Duration(n) * p.unit, true - } - - return 0, false } func (al *AgentLoop) handleSlashCommand(msg bus.InboundMessage) (bool, string, error) { diff --git a/pkg/agent/loop_directive_test.go b/pkg/agent/loop_directive_test.go index 37e6f04..63b7664 100644 --- a/pkg/agent/loop_directive_test.go +++ b/pkg/agent/loop_directive_test.go @@ -15,16 +15,6 @@ func TestParseTaskExecutionDirectives_RunCommand(t *testing.T) { } } -func TestParseTaskExecutionDirectives_NaturalLanguage(t *testing.T) { - d := parseTaskExecutionDirectives("你可以自动运行任务:整理日志,但是每到一个阶段给我报告一下任务完成情况") - if d.task != "整理日志" { - t.Fatalf("unexpected task: %q", d.task) - } - if !d.stageReport { - t.Fatalf("expected stage report enabled") - } -} - func TestParseTaskExecutionDirectives_Default(t *testing.T) { d := parseTaskExecutionDirectives("帮我看看今天的日志异常") if d.task != "帮我看看今天的日志异常" { @@ -59,8 +49,8 @@ func TestParseAutoLearnInterval_Invalid(t *testing.T) { } } -func TestParseAutoLearnIntent_StartNaturalLanguage(t *testing.T) { - intent, ok := parseAutoLearnIntent("请开始自动学习,每5分钟执行一轮") +func TestParseAutoLearnIntent_FallbackCommand(t *testing.T) { + intent, ok := parseAutoLearnIntent("autolearn start 5m") if !ok { t.Fatalf("expected intent") } @@ -72,8 +62,8 @@ func TestParseAutoLearnIntent_StartNaturalLanguage(t *testing.T) { } } -func TestParseAutoLearnIntent_StopNaturalLanguage(t *testing.T) { - intent, ok := parseAutoLearnIntent("先暂停自动学习") +func TestParseAutoLearnIntent_StopFallbackCommand(t *testing.T) { + intent, ok := parseAutoLearnIntent("autolearn stop") if !ok { t.Fatalf("expected intent") } @@ -82,18 +72,14 @@ func TestParseAutoLearnIntent_StopNaturalLanguage(t *testing.T) { } } -func TestParseAutoLearnIntent_StatusNaturalLanguage(t *testing.T) { - intent, ok := parseAutoLearnIntent("帮我看下自动学习状态") - if !ok { - t.Fatalf("expected intent") - } - if intent.action != "status" { - t.Fatalf("unexpected action: %s", intent.action) +func TestParseAutoLearnIntent_NoNaturalLanguageFallback(t *testing.T) { + if _, ok := parseAutoLearnIntent("请开始自动学习"); ok { + t.Fatalf("expected no fallback match") } } -func TestParseAutonomyIntent_StartNaturalLanguage(t *testing.T) { - intent, ok := parseAutonomyIntent("以后你自动拆解并自动执行任务,每15分钟主动找我汇报一次,研究方向是日志异常聚类") +func TestParseAutonomyIntent_FallbackCommand(t *testing.T) { + intent, ok := parseAutonomyIntent("autonomy start 15m log clustering") if !ok { t.Fatalf("expected intent") } @@ -103,13 +89,13 @@ func TestParseAutonomyIntent_StartNaturalLanguage(t *testing.T) { if intent.idleInterval == nil || *intent.idleInterval != 15*time.Minute { t.Fatalf("unexpected interval: %v", intent.idleInterval) } - if intent.focus != "日志异常聚类" { + if intent.focus != "log clustering" { t.Fatalf("unexpected focus: %q", intent.focus) } } -func TestParseAutonomyIntent_StopNaturalLanguage(t *testing.T) { - intent, ok := parseAutonomyIntent("先不要主动找我,关闭自主模式") +func TestParseAutonomyIntent_StopFallbackCommand(t *testing.T) { + intent, ok := parseAutonomyIntent("autonomy stop") if !ok { t.Fatalf("expected intent") } @@ -118,8 +104,8 @@ func TestParseAutonomyIntent_StopNaturalLanguage(t *testing.T) { } } -func TestParseAutonomyIntent_StatusNaturalLanguage(t *testing.T) { - intent, ok := parseAutonomyIntent("帮我看下自主模式状态") +func TestParseAutonomyIntent_StatusFallbackCommand(t *testing.T) { + intent, ok := parseAutonomyIntent("autonomy status") if !ok { t.Fatalf("expected intent") } @@ -138,28 +124,14 @@ func TestParseAutonomyIdleInterval(t *testing.T) { } } -func TestParseAutonomyIntent_NoFalsePositiveOnSingleTask(t *testing.T) { +func TestParseAutonomyIntent_NoNaturalLanguageFallback(t *testing.T) { if intent, ok := parseAutonomyIntent("请自动执行这个任务"); ok { t.Fatalf("expected no intent, got: %+v", intent) } } -func TestExtractAutonomyFocus_EmptyWhenNotProvided(t *testing.T) { - focus := extractAutonomyFocus("开启自主模式,每30分钟主动汇报") - if focus != "" { - t.Fatalf("expected empty focus, got: %q", focus) - } -} - -func TestExtractAutonomyFocus_KeepInnerBing(t *testing.T) { - focus := extractAutonomyFocus("开启自主模式,研究方向是日志聚类并关联异常根因,并且每30分钟主动汇报") - if focus != "日志聚类并关联异常根因" { - t.Fatalf("unexpected focus: %q", focus) - } -} - -func TestParseAutonomyIntent_ClearFocusNaturalLanguage(t *testing.T) { - intent, ok := parseAutonomyIntent("自主附带的方向执行完成了,可以去执行别的") +func TestParseAutonomyIntent_ClearFocusFallbackCommand(t *testing.T) { + intent, ok := parseAutonomyIntent("autonomy clear_focus") if !ok { t.Fatalf("expected intent") } @@ -167,3 +139,16 @@ func TestParseAutonomyIntent_ClearFocusNaturalLanguage(t *testing.T) { t.Fatalf("unexpected action: %s", intent.action) } } + +func TestExtractJSONObject_FromCodeFence(t *testing.T) { + raw := extractJSONObject("```json\n{\"action\":\"start\",\"confidence\":0.95}\n```") + if raw != "{\"action\":\"start\",\"confidence\":0.95}" { + t.Fatalf("unexpected json: %q", raw) + } +} + +func TestExtractJSONObject_Invalid(t *testing.T) { + if raw := extractJSONObject("no json here"); raw != "" { + t.Fatalf("expected empty json, got: %q", raw) + } +}